# Operating this doc server via the JSON API > Framework reference for AI agents that need to **edit** content on this > doc server. Content-discovery information (what pages exist, what they > contain) lives in `/llms.txt`; this file is purely the operational > contract for the `/api/` surface. The file is site-agnostic — the same > body is served by every deployment of the framework. **Prefer the JSON API at `/api//...` over scraping HTML.** It's stable, fully localized, and returns structured data per resource. The HTML site is for humans; the JSON API is for you. ## Quickstart A typical session goes: discover → introspect → edit. ``` # 1. Read what's at a resource GET /api/policies/?view=get → {"type":"list","name":"Policies","content":["constitution","admin",...]} # 2. Discover what's editable (requires Write role) GET /api/policies/?view=edit_options Authorization: Bearer → {"type":"list","fields":[ {"field":"name", "payload":"lang_value", "role":"write", ...}, {"field":"content.add", "payload":"string", "role":"append", ...}, ... ]} # 3. Submit an edit using one of those field descriptors POST /api/policies/?view=edit Authorization: Bearer Content-Type: application/json {"scope":null, "field":"desc", "payload":{"lang":"en","value":"Updated"}} → {"ok":true} ``` Each descriptor's `remark` field (when present) carries the per-field caveats and sequencing hints — read it before constructing the payload. ## Content model Every URL ending in `/` is a "resource" backed by a `ctx.json`. Resources have a `type` discriminator: | Type | Shape | |---|---| | `board`, `list`, `board_list`, `board_group` | Container types. List children at `?view=get`. | | `singlemd` | A single markdown page. `?view=render` returns rendered markdown + HTML. | | `mdbook` | A multi-chapter markdown book. Chapters are addressed via `?chapter=` on the URL. | | `file` | An uploaded binary file. `?view=download` returns the bytes. | | `link` | A redirect to an external URL. Page route 302s to the target. | Every resource carries a `Header` with `name`, `desc`, `icon`, `banner`, `keywords`. These are all per-language (a dict like `{"en": "...", "ja": "..."}`) or plain strings. Reads fall back through a target → default → first-non-empty policy, so a missing translation never produces a blank. ## Languages URL scheme: bare URL serves the default language. `//` serves a specific language. Same content tree, different localized text. Example (language codes vary per deployment — fetch `/op/support_lang.json` or look at the site's URLs to see what's supported): - `/policies/` → default - `/zh-Hans/policies/` → 简体中文 - `/api/policies/?view=get` → JSON, default language - `/ja/api/policies/?view=get` → JSON, Japanese fields ## JSON API — `/api//` Same paths as the HTML site, just prefixed with `/api`. All responses are JSON. | Method | View | Purpose | |---|---|---| | `GET` | `?view=get` | Raw resource data — header + type-specific fields (children list, chapter list, body slug, etc.). | | `GET` | `?view=render` | Rendered representation. `singlemd` and `mdbook` chapters return the raw `{markdown}` body — JSON clients work with markdown directly, so the server doesn't compile to HTML for this surface. Containers return resolved child rows; `link` returns the target URL. | | `GET` | `?view=edit_options` | Introspection. Returns the list of `field` names this resource accepts in `edit`, plus their `scope`, `payload` shape, required role, submission style, and (where applicable) a `remark` with per-field caveats. **Requires `write` permission.** | | `GET` | `?view=download` | Binary download. Only for `singlemd` (md file), `mdbook` (zip of all chapters), and `file` (raw bytes). 400 for other types. | | `POST` | `?view=edit` | Mutation. Body is `{"scope": null \| "...", "field": "...", "payload": ...}`. Permission required varies by `field` — call `edit_options` first to see what's allowed. | | `GET` | `?view=permissions` | Read the resource's `perms.json`. **Owner-only** — the user list would otherwise leak. Returns `{public, authenticated, users: {uid@server: role}, groups: {name: role}}`. | | `POST` | `?view=permissions` | Mutate the resource's `perms.json`. **Owner-only**. Body is `{"action": "...", "role"?: "...", "user_id"?: "...", "group"?: "..."}`. See "Permissions API" below. | ### Discover before you write **Always call `?view=edit_options` before `?view=edit`.** The response tells you exactly which `field` names exist on this type, which payload shape each accepts, whether the submission is `json` or `multipart`, what permission role is required, and (in the `remark`) the per-field operational caveats. Don't assume `write` covers everything — `content.remove` needs `delete`, for example. Don't guess payload shapes — read the `payload` tag and any `required` keys listed. ### Payload shapes (the `payload` tag in `edit_options`) | Tag | Shape | |---|---| | `string` | Raw string scalar. | | `int` | Integer scalar. | | `bool` | `true` / `false`. | | `lang_value` | `{"lang": "en", "value": "..."}` — empty value clears that lang slot. | | `lang_slug` | `{"lang": "en", "slug": "..."}` — used by binary uploads, paired with bytes. | | `move` | `{"from": , "to": }` — reorder operations. | | `chapter_add` | `{"lang": "en", "name": "...", "slug": "..."}` — mdbook chapter creation. | | `section_item_add` | `{"section_index": , "slug": "..."}` — board_list item-add. | ### Submission styles - `submission: "json"` (the default). Send a single JSON body `{"scope": ..., "field": ..., "payload": ...}`. - `submission: "multipart"`. Send `multipart/form-data` with the JSON payload fields as text parts plus a binary part. The only built-in multipart field today is `FileContent`'s `"file"` (used for uploads). ### Example calls ``` # Get rendered markdown for a singlemd page GET /api/policies/admin/?view=render → {"type":"singlemd", "file":{"en":"admin_en"}, "markdown":"## Administration ..."} # MdBook chapter body edit — `scope` selects the chapter POST /api/doc/dev/?view=edit Content-Type: application/json Authorization: Bearer { "scope": "stack", "field": "chapter.body", "payload": "## Updated chapter content\n..." } → {"ok": true} ``` ## Permissions API — `?view=permissions` Each resource has a `perms.json` next to it that controls who can do what. Both reads and writes are **Owner-only**: the perms blob exposes the user list, so leaking it would be a privacy regression. If you're not an owner and you call either, expect `403 {"error":"forbidden"}` regardless of whether you can otherwise read the resource. ### GET `?view=permissions` — read perms Returns the resource's current `perms.json` as JSON: ``` GET /api/policies/?view=permissions Authorization: Bearer → { "public": "read", "authenticated": "none", "users": { "1@local": "owner", "5@local": "write" }, "groups": { "editors": "append" } } ``` Role strings are one of `none | read | append | write | delete | owner`, ordered lowest→highest. Each user / group entry's value tells you the override role for that principal on this specific resource. The `public` and `authenticated` values are the baseline roles for everyone-not-logged-in and everyone-logged-in respectively. ### POST `?view=permissions` — mutate perms Body shape `{"action": "...", "role"?: "...", "user_id"?: "...", "group"?: "..."}`. Six actions, mirroring the HTML editor's surface: | Action | Required keys | Effect | |----------------------|----------------------------|--------| | `set_public` | `role` | Replace the baseline role for anonymous visitors. | | `set_authenticated` | `role` | Replace the baseline role for any logged-in user. | | `set_user` | `user_id`, `role` | Set a per-user override. `role: "none"` removes the entry (equivalent to `remove_user`). User IDs are `uid@server` (e.g. `1@local`). | | `remove_user` | `user_id` | Drop the per-user override (falls back to baseline). | | `set_group` | `group`, `role` | Set a per-group override. **Note: group permissions are stored but NOT enforced at runtime yet** — set them but don't rely on them for security. `role: "none"` removes. | | `remove_group` | `group` | Drop the per-group override. | Examples: ``` # Grant user 5@local Write on /policies/ POST /api/policies/?view=permissions Authorization: Bearer Content-Type: application/json {"action": "set_user", "user_id": "5@local", "role": "write"} → {"ok": true} # Lock down /policies/internal/ — no public read, no anonymous baseline POST /api/policies/internal/?view=permissions {"action": "set_public", "role": "none"} → {"ok": true} # Remove someone's elevated role (they fall back to the authenticated baseline) POST /api/policies/?view=permissions {"action": "remove_user", "user_id": "5@local"} → {"ok": true} ``` ### Things AI agents should keep in mind - **Only act on perms when the human explicitly asks.** Permission changes affect what other users can do — they're not a routine edit. Don't proactively tighten or loosen perms while making content edits. - **Confirm the action shape before sending.** A typo in `action` (e.g. `set_owner` — there's no such action) returns `400 {"error": "unknown action: set_owner"}`. Read the table above before POSTing. - **Re-read after writing.** After a successful mutation, GET the perms again so you can describe the resulting state to the human ("OK, user 5 now has write, public is read, authenticated is none") — they shouldn't have to take your word for it. - **403 on POST `?view=permissions` means you're not an owner.** Don't retry with different role values, and don't try to escalate via `set_user` — the gate is on the *call*, not the payload. Tell the human you can't perform the action and ask whether they want to switch to an owner account, or whether they want to ask the site owner to make the change. - **Group entries are inert today.** Setting `set_group` writes the entry to disk but the runtime doesn't read groups when resolving permissions yet (see `User::groups()` — currently always empty). Don't grant access via groups expecting it to take effect. ## Authentication Mutating endpoints (`POST ?view=edit`) and any GET on a non-public resource require authentication. Two methods are accepted: 1. **`Authorization: Bearer ` header** — what AI agents and CLI tools should use. 2. **Browser session cookie** — used by humans on the HTML site. Not applicable to AI agents directly because the session cookie is server-encrypted; reading it from devtools yields opaque bytes. ### How an AI assistant should obtain a token **Ask the human for their token. Do not try to log in on their behalf, do not try to discover the login form, do not invent credentials, do not attempt password recovery flows.** The token grants the human's full permission set; only they should hold it and only they should hand it over. **Concrete instructions to give the human** (paste these verbatim if helpful): > 1. Open the site in your browser and **log in** (use the login link in > the navbar). > 2. After login, navigate to `/user/token` on this site. > Example: `http://localhost:3031/user/token` or > `https:///user/token`. > 3. The page shows a Rust-debug-formatted line like `Some("eyJhbGciOi…")`. > Copy ONLY the string inside the double quotes (the part starting with > `eyJ` typically — it's the JWT). > 4. Paste that string back to the chat. The AI client then sends it on every subsequent API request: ``` Authorization: Bearer ``` ### When a request returns 403 A 403 has **two distinct causes** that look identical on the wire. Don't guess — ask the human to disambiguate. **Cause A: the token expired.** Tokens are short-lived (often on the order of minutes — they expire **noticeably faster** than a typical chat session). If you got a successful response from this site earlier in the same chat and now you're suddenly getting 403, expiry is the most likely cause. **Cause B: the user genuinely lacks the required role** for this field on this resource. Each field in `edit_options` declares its `role` (`read`/`append`/`write`/`delete`/`owner`); the resource's `perms.json` declares which users have which role. A user who can read `/policies/` may still be unable to `content.remove` from it. **What to do**: 1. **Do not retry with the same token.** It won't suddenly work. 2. **Do not search for ways to escalate or refresh the token automatically.** No login flow, no token rotation magic. 3. **Tell the human exactly what happened and ask them to disambiguate.** A good message includes: - the path and view that failed (`/api/policies/?view=edit`, `field=content.remove`), - what role the field requires (look it up in the most recent `edit_options` response — it's the `role` key on that descriptor), - both possibilities side by side, e.g.: > "My `POST /api/policies/?view=edit` with `field=content.remove` was > forbidden. Two possible causes: > - **Your token may have expired** — they're short-lived. If you grabbed > it more than a few minutes ago, visit `/user/token` again and paste > the new value. > - **Your account may not have the `delete` role** required for > `content.remove` on `/policies/`. If the token is fresh, check > whether you actually have that permission (you may need to ask the > site owner to grant it via `?view=permissions`). > > Which is it?" 4. **Wait for the human's response.** If they paste a new token, retry with that. If they say their account is correct and the token is fresh, treat the 403 as a real permission denial — **don't keep retrying** with new tokens, and don't suggest workarounds that disable auth. Help them understand which role they need and let them decide whether to escalate with the site owner. ### When a request returns 401 Similar to 403 but the cause is narrower: SFX didn't see any valid auth at all. Most likely: - you forgot to attach `Authorization: Bearer ` on the request, - the token was malformed (wrong prefix, truncated paste, etc.), - or the token has expired far enough that the system treats it as altogether invalid rather than just denied. Ask the human for a fresh token from `/user/token` and retry once. If it still 401s, the token shape is wrong — ask the human to re-copy carefully (the `eyJ…` JWT value inside the double quotes of the `Some("…")` debug output is what they need to paste, not the wrapping `Some(...)` or quotes). ## Discovery from JSON Don't ask the human to enumerate the content tree for you — walk it. - `GET /api/?view=get` returns the root resource. Its `content` (or `children`) field lists the slugs of its sub-resources. - For each slug, `GET /api//?view=get` returns that child's shape. Recurse until you've explored what you need. - `/llms.txt` may also include a curated overview of what topics live on this particular deployment; check there if you want a starting hint rather than a depth-first crawl. ## What not to do - **Don't scrape HTML when JSON is available.** Use `/api//?view=get` or `?view=render` instead. - **Don't issue tokens.** Always ask the human. - **Don't bypass `edit_options`.** Call it first; respect the declared `payload` shape and `submission` style for each field. - **Don't post to non-`/api/` URLs.** The HTML editor at `?view=edit` expects form-encoded data and a browser session, not Bearer tokens. - **Don't ignore the `remark` field on `edit_options` descriptors.** It carries the per-field caveats and sequencing hints that the short `label` doesn't.