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.ccis an app developed on the Webpath spec — the live reference host. Every example below runs against it.
https://name.knower.cc/ is one space; name.pathwayfs.com another.host + pathname, verbatim. No /api prefix on the data plane, no GUIDs._config.json, _links.json, _audit, llms.txt, .well-known/).application/problem+json with full-sentence, agent-relayable recovery hints in detail.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 disagree → 400 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.
| Method & path | Does |
|---|---|
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/logout | Clear the session. Idempotent 204. |
GET /api/v1/meReflects 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.
| Field | Value |
|---|---|
authenticated | true when a valid session cookie is present. |
email | The signed-in account's email, or null. |
ns | The space (hostname) this was read on. |
owner | true if the caller owns this space. |
ops | Effective ops here: owner → ["read","write","delete"]; a grantee → their granted ops (admin ⇒ all three); everyone else → ["read"]. |
v | Response schema version (1). |
Cache-Control: private, no-store, Vary: Cookie.
GET /api/v1/me/grantsReflects 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.
| Field | Value |
|---|---|
authenticated | true when a valid session cookie is present. |
email | The signed-in account's email, or null. |
grants | Array of {ns, path, ops, url, expires_at} — named grants only (excludes "*"), live rows only. url opens the shared path. Empty for anonymous callers. |
v | Response schema version (1). |
Cache-Control: private, no-store, Vary: Cookie.
The path is the address. Send the Bearer header.
| Verb | Path | Does |
|---|---|---|
GET | /<path> | Read a file (clean URL renders; extension = raw bytes). |
PUT | /<path> | Create or replace (body = file content). |
PATCH | /<path>?meta | Update metadata (JSON body). |
PATCH | /<path>?append / ?prepend | Extend the file (line-safe for csv/jsonl, byte-pure otherwise). |
PATCH | /<path>?replace | In-place WYSIWYG HTML edit (patch batch; HTML only). |
DELETE | /<path> | Delete a file. |
PUT also accepts ?append / ?prepend as a shorthand for additive writes.
GET)| Param | Returns |
|---|---|
| (none, clean URL) | Rendered view: .md→HTML, or serves .html. |
| (extension) | Raw bytes (/page.md, /page.html). |
?list | JSON directory listing (agents). GET /dir/?list. |
?browse | HTML directory browser (humans). Sortable. |
?meta | The object's metadata as JSON. |
?qr | QR code of the URL — ?qr (negotiated), ?qr=svg, ?qr=txt. |
?links | This file's links view {outgoing, incoming}. |
?audit | This path's activity timeline (owner-only). |
?og | Generated 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.
?visibility=private|unlisted|public sets visibility on write (private is default).If-Match / If-None-Match preconditions give compare-and-set (CAS); 412 on mismatch.Content-Type is inferred from the extension. Host/space upload policy may restrict file types.If-Match with the ETag you saw.DELETE of a directory/prefix requires ?confirm=<space-hostname>.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.
Every file carries a visibility, stored on the object and defaulting to private.
| Level | Who can read |
|---|---|
private (default) | The owner, a covering key, and anyone granted read (see Permissions). Nobody else. |
unlisted | Anyone with the URL; hidden from listings and search. |
public | Anyone; 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).
Four kinds of caller can touch a space, evaluated in this order — the first that authorizes wins:
pth_ key whose scope covers the request.A key carries a scope. The request is checked against it in this order, and the first failure is the response:
| Check | Rule | Failure |
|---|---|---|
| signature + expiry | Ed25519 (verified via /.well-known/jwks.json); exp in the future. | 401 token-invalid / token-expired |
| space | the key's namespace equals this host. | 403 out-of-scope |
| generation | the key's gen matches the space's current key generation. | 401 token-revoked |
| op | the method's op is in the key's ops. | 403 out-of-scope |
| path | the 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 |
| revocation | the key's jti isn't on the revoke list. | 401 token-revoked |
Method → op: GET/HEAD → read, PUT/PATCH → write, DELETE → delete. 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.
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.
DELETE /api/v1/tokens/:jti denylists that jti until its natural expiry. The minting colo blocks it on the next request; every other colo within ≤60s.POST /api/v1/spaces/:ns/revoke-all bumps the space's key generation, so every key minted before the bump fails instantly and globally (generation is checked against the space record already loaded — no propagation lag). Reach for this the moment a key leaks.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
}}'
"*" = any signed-in account). The email is the account, so the grant is waiting the first time that person signs in.read, write, admin. admin implies read+write+delete and lets the grantee re-share (read/write only — never admin, never to "*"). A plain write grant is an editor: it can create, append/prepend, ?replace, and ?meta, but it is content-only — it cannot change visibility, and deleting needs admin.write without read is a drop-box grant: create-only (no overwrites), forced private, honoring optional formats, max_size (≤10 MB), and expires_at. Good for "each named person may leave files here but can't read the others'."/reports/), everything beneath it — with one floor for "*": a "*" (any-signed-in) directory grant surfaces only public/unlisted children; it never crosses a private child's visibility, or one folder grant would expose every private sibling to everyone with an account. To share private content with any-signed-in, grant the exact path; a named-email directory grant still cascades over private (a deliberate share with a known person). A grant's formats restricts writes, never reads. Set "email": null to revoke that path's grant. Up to 50 entries per PATCH."notify": true beside sharing ({"sharing":{…},"notify":true}) to email each freshly-granted named person an invitation — who shared, the URL, what they can do. Never "*" (no inbox), never a removal; omit it and nothing is sent. From the other side, a grantee finds everything shared with them at GET /api/v1/me/grants.PATCH ?meta echoes back the current sharing block._access.jsonTo 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" }
write is "anyone" (no credential), "keyed" (any live key for this space, whatever its path scope), or "owner"/absent (no ambient grant)._access.json, walking up from the file's directory to the space root, wins.PUT only (no append/prepend), and bounded by formats + max_size (default 1 MB, hard ceiling 10 MB) plus the host/space upload policy._access.json itself needs the owner session or a whole-space write key, and the body is schema-validated (422 bad-access-rule on a typo — a mistake can't silently become a no-grant).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.
PUT, ?append, ?prepend, and in-place ?replace edit creates an immutable version. The response carries an x-version-id header (a ver_… id) plus a version field in the JSON body.PATCH /<path>?meta) does not create a version.| Verb | Path | Does |
|---|---|---|
GET | /<path>?versions | List 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). |
?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.
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 /<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.
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.
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)
Every space serves these as files. Most are synthesized on read (never stored, so they can't drift).
| Path | Methods | What |
|---|---|---|
/llms.txt | GET | The 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.json | GET, PATCH | Space config projection: key_generation, uploads policy, browse defaults. PATCH edits only uploads and browse (owner session or whole-space write key). |
/_links.json | GET, POST ?reindex | Space-wide links index (outgoing/incoming/broken/orphan). Owner-only GET. POST ?reindex rebuilds in bounded batches (paginate with ?after=). |
/_search | GET, POST ?reindex | GET reports index stats; POST ?reindex rebuilds it. Search reads are GET /?search=<terms>. |
/_audit | GET | Space-wide activity feed (owner-only). Filter ?action/?actor/?outcome/?from/?to, page ?limit/?cursor. Archive: GET /_audit/<yyyy-mm>.jsonl. |
/_stats, /_stats.json | GET | Visitor stats (read-side twin of _audit), synthesized. /_stats.json?path=<rel> scopes to one file. |
/.well-known/jwks.json | GET | Public keys that verify pth_ JWTs. Public, cacheable. |
/.well-known/openapi.json | GET | Machine-readable OpenAPI spec (platform hosts only). Scalar UI at /openapi. |
| Method & path | Auth | Does |
|---|---|---|
POST /join {"name"} | none | Mint <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"} | session | Account-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-all | session | Owner emergency reset — bumps the key generation so every prior key fails instantly. |
pth_ tokens)| Method & path | Auth | Does |
|---|---|---|
POST /api/v1/tokens | session | Mint a scoped key. Body: {label, ttl_seconds, scope}. Secret returned once. → 201 |
GET /api/v1/tokens | session | List your live keys (secrets never re-shown). |
DELETE /api/v1/tokens/:jti | session | Revoke one key by jti (effective globally ≤60s). |
Scope (scope object on mint):
namespaces — the space the key works on (required; must be one you own).ops — any of read · write · delete · share · subscribe · admin (default ["read","write"]). admin implies read+write+delete and is the maximal "give-a-key" tier; it still grants no owner-session powers (key minting, space creation, revoke-all stay session-only).paths — path prefixes the key is limited to (default ["/"] = whole space; a trailing slash matches the whole subtree).formats — optional write-format allowlist (extensions).ttl_seconds — 1 hour to 365 days (default 7 days).{
"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).
# 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