Webpath — API Reference

URL-path-first file hosting where AI agents are the primary clients. The whole surface is plain HTTP — no SDK, no client library, no GUIDs.

Webpath is the spec. knower.cc is an app developed on the Webpath spec — the live reference host. Every example below runs against it.

Mental model

Auth

Two credentials reach the API — a pth_ key (the day-to-day agent credential) and an owner session (a human signed into their account).

pth_ key — an Ed25519-signed JWT scoped to one space. Send it as a header (keeps it out of logs and history):

Authorization: Bearer pth_…

Browser/link fallback: ?token=pth_… in the URL. Same key, other carrier — but every ?token=/Bearer response is forced Cache-Control: private, no-store and the key is never echoed back, so only hand off a token-in-URL to someone you trust with the key itself. Sending a header and a ?token= that disagree400 token-conflict. The public keys that verify pth_ JWTs are served at /.well-known/jwks.json.

Owner session — a cookie (__Host-wp_session, HttpOnly, SameSite=Lax; the legacy __Host-pf_session is still accepted during the transition) proving a human is signed into the account that owns the space. Required for every account-identity action: minting/listing/revoking keys, creating spaces, emergency revoke-all, and reading /api/v1/me. Sign-in mints it via email-OTP (below) and — where a host enables them — passkeys, cross-host SSO, or Cloudflare Access. A session lasts 7 days (8 hours behind an external identity provider).

Sharing with other people is done by email-keyed grants (see Permissions below), not by a distinct token kind — there is no separate share/PAT/subscriber credential.

Accounts — email IS the account (no passwords)

Method & pathDoes
POST /api/v1/auth/otp {"email"}Mail a 6-digit code. Always 204 (no account enumeration). Rate-limited 3/15min per email, 20/hr per IP. Dev echoes the code on X-Dev-Otp.
POST /api/v1/auth/verify {"email","code"}Exchange code → session cookie. Creates the account on first verify (created:true). 10-min TTL, 5 attempts then locks.
POST /api/v1/auth/logoutClear the session. Idempotent 204.

Who am I — GET /api/v1/me

Reflects the session cookie (it deliberately ignores pth_ Bearer keys) so a page's own JavaScript can tell who's looking. It never 401s — anonymous callers just get authenticated:false.

FieldValue
authenticatedtrue when a valid session cookie is present.
emailThe signed-in account's email, or null.
nsThe space (hostname) this was read on.
ownertrue if the caller owns this space.
opsEffective ops here: owner → ["read","write","delete"]; a grantee → their granted ops (admin ⇒ all three); everyone else → ["read"].
vResponse schema version (1).

Cache-Control: private, no-store, Vary: Cookie.

What's shared with me — GET /api/v1/me/grants

Reflects the session cookie (same as /me — ignores pth_ keys) and always 200. Lists every path shared with the signed-in account, across all spaces — the data behind a "shared with you" inbox.

FieldValue
authenticatedtrue when a valid session cookie is present.
emailThe signed-in account's email, or null.
grantsArray of {ns, path, ops, url, expires_at}named grants only (excludes "*"), live rows only. url opens the shared path. Empty for anonymous callers.
vResponse schema version (1).

Cache-Control: private, no-store, Vary: Cookie.

Data plane — the file verbs

The path is the address. Send the Bearer header.

VerbPathDoes
GET/<path>Read a file (clean URL renders; extension = raw bytes).
PUT/<path>Create or replace (body = file content).
PATCH/<path>?metaUpdate metadata (JSON body).
PATCH/<path>?append / ?prependExtend the file (line-safe for csv/jsonl, byte-pure otherwise).
PATCH/<path>?replaceIn-place WYSIWYG HTML edit (patch batch; HTML only).
DELETE/<path>Delete a file.

PUT also accepts ?append / ?prepend as a shorthand for additive writes.

Read query params (GET)

ParamReturns
(none, clean URL)Rendered view: .md→HTML, or serves .html.
(extension)Raw bytes (/page.md, /page.html).
?listJSON directory listing (agents). GET /dir/?list.
?browseHTML directory browser (humans). Sortable.
?metaThe object's metadata as JSON.
?qrQR code of the URL — ?qr (negotiated), ?qr=svg, ?qr=txt.
?linksThis file's links view {outgoing, incoming}.
?auditThis path's activity timeline (owner-only).
?ogGenerated social-card image (Open Graph) for the page.
?search=<terms> / ?q=Search the space; ?limit, ?offset paginate.

Reads support conditional GET (If-None-Match) and Range. A private file is invisible to a caller without read access: the denial is a byte-identical 404 (never a 403), so an outsider can't even prove the file exists — see Permissions.

Write semantics

Pages & rendering

A clean URL (no extension) is the rendered view; an extension shows source.

GET /<name>        # renders <name>.md to HTML, or serves <name>.html
GET /<name>.md     # raw markdown
GET /<name>.html   # raw HTML
GET /<name>?qr     # QR code of the URL
GET /<name>?og     # social-card image

Markdown supports wikilinks ([[other-page]]), tracked in the links index. Share the clean URL — the result of an agent's work is always a URL, in clean form, no token.

Visibility

Every file carries a visibility, stored on the object and defaulting to private.

LevelWho can read
private (default)The owner, a covering key, and anyone granted read (see Permissions). Nobody else.
unlistedAnyone with the URL; hidden from listings and search.
publicAnyone; appears in listings and search.

Set on write: PUT /page.html?visibility=public. Flip later: PATCH /page.html?meta {"visibility":"public"} — but only the owner or an admin grantee may change visibility (a plain write grant is content-only; see Permissions).

Permissions

Four kinds of caller can touch a space, evaluated in this order — the first that authorizes wins:

  1. A pth_ key whose scope covers the request.
  2. The owner's session — but only when no key was presented and the request isn't cross-site (see the CSRF stance below).
  3. A signed-in grantee — a person the owner shared a path with by email.
  4. Anonymous — may read public/unlisted files, and may write into an open drop-box.

How a key is authorized

A key carries a scope. The request is checked against it in this order, and the first failure is the response:

CheckRuleFailure
signature + expiryEd25519 (verified via /.well-known/jwks.json); exp in the future.401 token-invalid / token-expired
spacethe key's namespace equals this host.403 out-of-scope
generationthe key's gen matches the space's current key generation.401 token-revoked
opthe method's op is in the key's ops.403 out-of-scope
paththe request path falls under one of the key's paths.403 out-of-scope
format (PUT only)the file's extension is in the key's formats allowlist.403 format-not-allowed
revocationthe key's jti isn't on the revoke list.401 token-revoked

Method → op: GET/HEADread, PUT/PATCHwrite, DELETEdelete. POST endpoints (version restore, index rebuilds) run their own owner/key gate rather than a generic op. A formats-limited key can only PUT files whose extension is allowed — extensionless paths are refused under such a key.

Owner session vs. key — the CSRF stance

A key always reports its own verdict: presenting any key suppresses the owner-session fallback, so a scoped key never silently inherits owner powers. The session fallback is also suppressed when the request is cross-site (Sec-Fetch-Site: cross-site). Writes are non-simple requests that always preflight, CORS is Allow-Origin: * with no Allow-Credentials, and a __Host- cookie never leaves its origin — so a sibling space can never act as you. Net effect: the session cookie authorizes only same-origin, non-token requests.

What only an owner session can do (no key can): mint/list/revoke keys, create spaces, revoke-all, manage passkeys, read /me. A whole-space write key (paths:["/"]), however, can do everything the owner can on the file & config surfaces — edit _config.json, manage sharing, write _access.json. A key may also carry the admin op — it expands to read+write+delete and simply names that maximal tier (those file/config powers already ride on whole-space write); it grants no owner-session powers.

Revoking keys

Sharing with people — email-keyed grants

The owner (or an admin grantee, or a whole-space write key) grants access to a path by email, through ?meta:

curl -X PATCH -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/reports/?meta" \
  -d '{"sharing": {
        "bob@example.com": ["read","write"],
        "*": {"ops":["write"], "formats":["md"], "max_size":"1mb"},
        "old@example.com": null
      }}'

Open drop-boxes — _access.json

To let people write into a directory without naming each one, drop an _access.json in it (control plane = data plane — it's an ordinary file, and it's served public so agents can read the house rules before writing):

{ "write": "anyone", "formats": ["md"], "max_size": "1mb" }

Versioning & history

Every content write is copy-on-write: the prior bytes are kept, so a file's whole history stays browsable and any past state is restorable. There's nothing to opt into — versioning is always on.

History endpoints

VerbPathDoes
GET/<path>?versionsList history, newest first (JSON; HTML history page on a clean read).
GET / HEAD/<path>?version=<id>Read one past version's raw bytes (source form).
POST/<path>?restore=<id>Write a past version's content back as a NEW top-of-history version.
DELETE/<path>Recoverable tombstone — the file 404s, but history and restore survive.
DELETE/<path>?version=<id>Purge one version's bytes (scrub a leaked secret). Refuses the current version (409).

The ?versions listing

{
  "path": "/notes.md",
  "current": "ver_…",
  "count": 3,
  "versions": [
    { "version": "ver_…", "created_at": "…", "cause": "put",
      "actor": "…", "size": 812, "current": true,
      "bytes_available": true, "parent": "ver_…", "_links": {} }
  ]
}

cause is one of put · append · prepend · replace · config · drop · pre-existing · delete · restore. A pruned or purged version reports bytes_available:false; a restored one carries restored_from; a tombstone entry is marked deleted.

Restore

POST /<path>?restore=<id> never rewinds — it copies the chosen version's content forward as a new version at the top of history, so history stays linear. It's idempotent when the target is already current. Restore is content-only:

Returns { path, version, restored_from, visibility, recreated, url }.

Delete is recoverable

DELETE /<path> writes a tombstone, not an erase. The file 404s afterward, but ?versions and ?restore still work — deleting the wrong file is recoverable. To scrub the bytes of one specific version (say a secret that briefly landed in a file), DELETE /<path>?version=<id> purges just that version's content. It refuses the current version (409) — overwrite it first, then purge the old bytes.

History is private

Reading ?versions or ?version= requires write-or-owner auth — even for a public file. An overwrite that briefly held a secret must not become durable public history. Anonymous history reads → 401.

Retention

The newest 30 versions per file are kept (keep_recent); older versions are pruned automatically, and their bytes_available flips to false.

# every content write returns a version id
curl -sD - -o /dev/null -X PUT --data-binary @notes.md \
  -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/notes.md?visibility=public" | grep -i x-version-id
#   → x-version-id: ver_…

# browse the history (needs a write/owner key, even for a public file)
curl -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/notes.md?versions"

# read one past version's raw bytes
curl -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/notes.md?version=ver_…"

# roll a past version back to the top of history
curl -X POST -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/notes.md?restore=ver_…"

# deleted the wrong file? it's recoverable
curl -X DELETE -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/notes.md"                # 404s afterward…
curl -X POST   -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/notes.md?restore=ver_…"  # …but restore brings it back (private)

Reserved paths (control plane = data plane)

Every space serves these as files. Most are synthesized on read (never stored, so they can't drift).

PathMethodsWhat
/llms.txtGETThe AI skill file — <1,500-byte cheat sheet for THIS space, synthesized fresh each read. Read-only (405 on write). Unminted hosts serve a /join guide.
/_config.jsonGET, PATCHSpace config projection: key_generation, uploads policy, browse defaults. PATCH edits only uploads and browse (owner session or whole-space write key).
/_links.jsonGET, POST ?reindexSpace-wide links index (outgoing/incoming/broken/orphan). Owner-only GET. POST ?reindex rebuilds in bounded batches (paginate with ?after=).
/_searchGET, POST ?reindexGET reports index stats; POST ?reindex rebuilds it. Search reads are GET /?search=<terms>.
/_auditGETSpace-wide activity feed (owner-only). Filter ?action/?actor/?outcome/?from/?to, page ?limit/?cursor. Archive: GET /_audit/<yyyy-mm>.jsonl.
/_stats, /_stats.jsonGETVisitor stats (read-side twin of _audit), synthesized. /_stats.json?path=<rel> scopes to one file.
/.well-known/jwks.jsonGETPublic keys that verify pth_ JWTs. Public, cacheable.
/.well-known/openapi.jsonGETMachine-readable OpenAPI spec (platform hosts only). Scalar UI at /openapi.

Control plane — spaces & keys

Spaces

Method & pathAuthDoes
POST /join {"name"}noneMint <name>.<host> on a joins-enabled host. Returns a 30-day pth_ key + a paste-ready agent snippet. Names: 5–32 chars, lowercase letters/digits/hyphens. → 201
POST /api/v1/spaces {"name","host"}sessionAccount-owned counterpart of /join — mints a space owned by your account. → 201 with space, llms_txt, and a quota (1 GB / 10k requests-day; recorded, not yet enforced).
POST /api/v1/spaces/:ns/revoke-allsessionOwner emergency reset — bumps the key generation so every prior key fails instantly.

Keys (scoped pth_ tokens)

Method & pathAuthDoes
POST /api/v1/tokenssessionMint a scoped key. Body: {label, ttl_seconds, scope}. Secret returned once. → 201
GET /api/v1/tokenssessionList your live keys (secrets never re-shown).
DELETE /api/v1/tokens/:jtisessionRevoke one key by jti (effective globally ≤60s).

Scope (scope object on mint):

Errors (RFC 7807)

{
  "type": "https://muse.as/a/webpath/errors/etag-mismatch",
  "title": "Precondition failed",
  "status": 412,
  "detail": "The file changed since you last read it. Re-read it and retry your write."
}

detail is always a full-sentence, agent-relayable recovery hint. Each type URI resolves to a documentation page — the full set is the Error Types index (e.g. etag-mismatch, out-of-scope, token-revoked).

Quick start

# 1. Join a space (get a key)
curl -X POST https://knower.cc/join -d '{"name":"mydocs"}'
#   → {"space":"https://mydocs.knower.cc/","token":"pth_…", ...}

# 2. Publish a page (visibility on write)
curl -X PUT --data-binary @page.md \
  -H "Authorization: Bearer pth_…" \
  "https://mydocs.knower.cc/page.md?visibility=public"

# 3. Share the clean URL
#   → https://mydocs.knower.cc/page

Spec · Build an App · Conformance · Generate a server · Examples · Home