Everything a Webpath host can do is plain HTTP against a space — no SDK. A space is a hostname:
https://<space>.<host>/<path>, and the file's key is that URL. This is the minimum to build a webapp that logs in and creates / reads / updates / deletes files.
knower.cc is an app developed on the Webpath spec — the live host
these snippets target. The examples page shows a real canvas app
(rooms.knower.cc) built with exactly this guide.
| Mode | Credential | Use it for | Sent as |
|---|---|---|---|
| Session | __Host- cookie (email-OTP) | a logged-in human owner in a browser | cookie, automatic |
Key (pth_…) | path-scoped bearer token | agents, scripts, cross-origin browser apps | ?token=pth_… or Authorization: Bearer pth_… |
CORS (read this before building a browser app): the data plane sends
Access-Control-Allow-Origin: * (methods GET HEAD PUT PATCH POST DELETE OPTIONS,
request headers Authorization, Content-Type, If-Match, exposes ETag, Content-Location).
Wildcard * means cookies do NOT work cross-origin — the browser will not attach the
session to another origin.
pth_ key via ?token= (cookies are useless cross-origin). Mint the key once (§3) and store it.Cookie writes are also CSRF-guarded: a PUT/PATCH/DELETE carrying a session cookie from a
cross-site context is rejected 403. Tokens carry no such guard (they're explicit, not ambient) —
another reason a cross-origin app uses a key.
Never put a key in a query string you log or render — responses to ?token= requests are
Cache-Control: private, no-store and the server never echoes the token back.
// 1. request a 6-digit code (always 204 — no account enumeration)
await fetch(`${HOST}/api/v1/auth/otp`, {
method: "POST", body: JSON.stringify({ email })
});
// 2. verify → sets the session cookie (use credentials:"include" for same-origin)
const r = await fetch(`${HOST}/api/v1/auth/verify`, {
method: "POST", credentials: "include",
body: JSON.stringify({ email, code })
}); // 200 = signed in · 401 otp-invalid (body has attempts_remaining)
On local dev the code comes back in the X-Dev-Otp response header of the /otp call,
so you can test the flow without a real inbox.
Rate limits: 3 codes / email / 15 min, 5 wrong attempts then the code dies. HOST is the
platform host (e.g. https://knower.cc) or the space's own host.
Account-less, one shot (great for bootstrapping an app/agent):
const j = await (await fetch(`${HOST}/join`, {
method: "POST", body: JSON.stringify({ name: "myapp" })
})).json();
// → 201 { space:"https://myapp.knower.cc/", token:"pth_…", expires_at, llms_txt, paste }
Signed-in owner (session cookie from §2):
// create the space
await fetch(`${HOST}/api/v1/spaces`, { method:"POST", credentials:"include",
body: JSON.stringify({ name:"myapp", host:"knower.cc" }) }); // 201
// mint a scoped key (shown ONCE)
const k = await (await fetch(`${HOST}/api/v1/tokens`, { method:"POST", credentials:"include",
body: JSON.stringify({
label: "myapp-web",
ttl_seconds: 2592000, // 1h … 1yr (default 7d)
scope: { namespaces:["myapp.knower.cc"], ops:["read","write","delete"], paths:["/"] }
}) })).json();
// → 201 { token:"pth_…", jti, scope, expires_at }
await fetch(`${HOST}/api/v1/tokens`); // list (label+jti+expiry, never the secret)
await fetch(`${HOST}/api/v1/tokens/${jti}`, { method:"DELETE" }); // revoke (≤60s globally)
Key scope: ops ⊆ read write delete share subscribe (never admin). paths are prefixes
under the space — ["/"] = whole space, ["/notes/"] = that subtree only. Optional
formats:["md"] limits writable extensions. A key only works on its one hostname.
All against the space's own URL: https://<space>.<host>/<path>. Pass ?token=… (or the
Authorization header). The object key is the URL path.
const SPACE = "https://myapp.knower.cc";
const auth = `token=${TOKEN}`;
// CREATE / UPDATE → 201 (new) | 200 (overwrite). ETag header + JSON body:
// { path, etag, size, visibility, url }. Body ≤ 10 MB.
await fetch(`${SPACE}/notes/hello.md?${auth}&visibility=public`, {
method: "PUT", body: "# Hello" });
// READ → exact bytes. Clean URL renders; the extension URL is verbatim source.
await fetch(`${SPACE}/notes/hello.md?${auth}`); // text/markdown source
await fetch(`${SPACE}/notes/hello`); // rendered HTML (public, no auth)
// LIST a directory → JSON of entries. Use ?list: a bare GET of a directory path
// returns the browse HTML shell to a browser (Accept: text/html).
await fetch(`${SPACE}/notes/?list&${auth}`);
// METADATA
await fetch(`${SPACE}/notes/hello.md?meta&${auth}`); // { path, size, etag, visibility, tags, … }
await fetch(`${SPACE}/notes/hello.md?meta&${auth}`, { // edit metadata only
method:"PATCH", body: JSON.stringify({ name:"Hello", description:"first note", tags:["demo"] }) });
// DELETE → 204
await fetch(`${SPACE}/notes/hello.md?${auth}`, { method:"DELETE" });
Extras worth knowing
PUT …?append / ?prepend (line-oriented types: .md .txt .jsonl .csv …). Concurrency-safe.If-Match: <etag> on PUT/PATCH; a stale tag → 412 (re-read, retry).?visibility=public | private | unlisted on write; default private. Private files are invisible to anonymous readers (plain 404, no existence leak). Flip later with PATCH ?meta {"visibility":"public"}.GET /?search=<terms> ranked across the space.Failures are RFC 7807 application/problem+json with a human-readable detail you can
show the user verbatim, e.g. 401 token-revoked, 403 out-of-scope, 404, 409,
412 (stale If-Match), 415 file-type-not-allowed, 422 (bad name/scope), 429 (rate limit).
A dead key on a public read is ignored (still 200); on a write it's 401.
llms.txt (auto-generated agent guide, read-only), _config.json (space settings, PATCH-only),
_stats, _audit, _links.json, .well-known/…, and anything under /_/. A _-prefixed
basename is never user-writable.
Visibility (§4) is the anonymous-public dial. To let a named person in without making a file public, grant them the path by email — one call, no new verb, honored for that person's signed-in session:
// grant bob read+write on one file; notify:true emails him an invitation (URL + what he can do)
await fetch(`${SPACE}/notes/plan.md?meta&${auth}`, { method: "PATCH",
body: JSON.stringify({ sharing: { "bob@example.com": ["read", "write"] }, notify: true }) });
// revoke — immediate, no artifact to chase (the credential is his session)
await fetch(`${SPACE}/notes/plan.md?meta&${auth}`, { method: "PATCH",
body: JSON.stringify({ sharing: { "bob@example.com": null } }) });
read · read+write = editor (create/overwrite/append) · admin (also delete + re-grant read/write). A grantee can never flip visibility or re-grant admin./x.md one file · /dir/ a whole subtree · / the whole space."*" = any signed-in account (never admin). Omit notify and nothing is emailed; it never emails "*" or a removal.GET /api/v1/me/grants.Two patterns — decide which your app is:
{"sharing":{"*":{"ops":["read","write"]}}} on /board/. Files everyone should read must be public or unlisted (a "*" folder grant does not expose private children — see the floor).The private floor (design around this): a "*" (any-signed-in) grant on a folder/subtree
surfaces only public/unlisted children — it will never read a private child, because that
would leak every private sibling to everyone with an account. To share private content, grant the
exact path (to a person or to "*"), or grant a named person the folder (a deliberate
share). A grant's formats restricts writes, never reads — it is not a privacy control.
Non-executable by default: a shared editor may write data (.json .md .txt …) but not active
content (.html .js .css .svg …) unless the owner opts that format into the grant — so a co-writer
can never replace your app.
POST /api/v1/auth/otp then /api/v1/auth/verify./join (anon) or /api/v1/spaces (owner), then mint a pth_ key.PUT to save, GET …/ to list, GET to view, PATCH ?meta/?visibility, DELETE.That's the whole product: HTTP verbs on URLs, one key, no GUIDs.
Want proof it works end to end? rooms.knower.cc is a single-file canvas app — login, a space, a key, and CRUD — built from exactly this sheet. See Examples.
→ Spec · API Reference · Conformance · Generate a server · Examples · Home