Webpath — Build an App

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.


1. Two ways to authenticate

ModeCredentialUse it forSent as
Session__Host- cookie (email-OTP)a logged-in human owner in a browsercookie, automatic
Key (pth_…)path-scoped bearer tokenagents, 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.

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.


2. Log in (email is the account, no passwords)

// 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.


3. Get a space and a key

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: opsread 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.


4. CRUD — the data plane

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


5. Errors

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.


6. Reserved paths (don't treat as user files)

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.


7. Multi-user — share a path with a person

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 } }) });

Two patterns — decide which your app is:

  1. Shared surface — one board everyone co-edits, all data meant to be co-readable. Grant the folder: {"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).
  2. Per-item private — each item private to its own people (a rooms / multi-tenant app). Grant per file, per email. Never grant a folder to reach a private tenant's data.

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.


Minimal app shape

  1. Login screenPOST /api/v1/auth/otp then /api/v1/auth/verify.
  2. Pick/create a space/join (anon) or /api/v1/spaces (owner), then mint a pth_ key.
  3. CRUD screenPUT 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