Web API
The HTTP API of agboard serve. How a request is handled, including the write guard, is described in Server and web UI.
Task writes accept optional X-Agboard-Language: en or ja for generated history. The board and monitor send their current UI language. Missing, repeated, or unsupported values fall back to the task body’s language. This affects only generated text for that request; explicit reasons and stored history stay unchanged. See History.
Endpoints
| API | Contract |
|---|---|
GET / | HTML with the write token in meta[name=agboard-token]. |
GET /api/projects | projects (id, name, createdAt) and catalogVersion, without tasks. 200 even when empty. |
POST /api/projects/{projectId}/tasks | Creates a Todo/Idle task from title, body, acceptance, assignee, isDraft. The server composes and validates the frontmatter. 201. |
POST /api/projects/{projectId}/tasks/multipart | Creates a regular task with images, or an AI task from a short request with its optional epic and images, in one multipart submission. See below. |
PATCH /api/projects/{projectId}/tasks/{id} | Updates title, body, acceptance, and optional finalize: true on a draft task. Body alone keeps the frontmatter; acceptance alone keeps the body; description with body is 422 invalid_value. Last write wins. Non-draft is 409 task_not_draft. |
PUT /api/projects/{projectId}/tasks/{id}/draft | Sets isDraft. A running task is 409 invalid_state_for_draft. |
PUT /api/projects/{projectId}/tasks/{id}/epic | {epicId: string | null}. Unknown EPIC-ID is 404 epic_not_found. |
POST /api/projects/{projectId}/tasks/{id}/move | taskStatus, optional reason, and optional fromStatus, the status the page showed when the user chose the move. With fromStatus, a task that is no longer in that status is 409 task_status_changed and nothing is written; the body adds currentStatus, the status the task has now, so the page can show it and the user can move again from there. The board's move dialog always sends fromStatus. |
POST /api/projects/{projectId}/tasks/{id}/answer | requestId, optional choiceId and text, matched under the lock. Both empty is 422; an unknown choice is 422 request_choice_unknown; a changed request is 409 request_changed. |
POST /api/projects/{projectId}/tasks/{id}/agent-status | Changes the run state within the state rules. The UI has no entry point for it. |
DELETE /api/projects/{projectId}/tasks/{id} | Deletes archived tasks only; others are 409 task_not_archived. 204, or 404 when already gone. |
…/tasks/{id}/attachments, …/attachments/{attachmentId} | Task images (GET, POST, DELETE), matched by ProjectId, task ID, and attachment ID. |
GET/POST /api/projects/{projectId}/epics | List and create epics (id, name, color, isDraft, document or body). No acceptance input. 201. |
GET/PATCH/DELETE /api/projects/{projectId}/epics/{epicId} | Read, or update name, color, isDraft, document or body, and priority. Keeps the body and unknown metadata. Saves EPIC.md before epic.json. DELETE answers 204; it is refused with 409 epic_in_use while any task, archived ones included, names the epic. |
…/epics/{epicId}/attachments, …/{attachmentId} | Epic images. |
POST …/tasks/{id}/archive, POST …/tasks/{id}/restore | No body. Repeating either on a task already in that state returns the same task unchanged. |
POST …/tasks/archive-done | {ids: number[]}; empty, duplicate, or invalid IDs are 422. Returns {tasks, archivedCount}. |
GET /api/projects/{projectId}/archive | Archived tasks with bodies, full history, and attachment metadata, plus epics from the same snapshot. Each task's epicId resolves against that list; a task without an epic, or one whose epic is missing, has no label. |
GET /api/projects/{projectId}/summary | The list data for the board: project, version, tasks, epics, and problems, without any body text. See Summary and Detail. |
GET /api/projects/{projectId}/tasks/{id}/detail | One task's body, acceptance conditions, and history as an NDJSON stream. Archived tasks are read the same way. |
GET /api/projects/{projectId}/epics/{epicId}/detail | One epic's document as an NDJSON stream. |
GET /api/projects/{projectId}/docs/treeGET /api/projects/{projectId}/docs/fileGET /api/projects/{projectId}/docs/assetsPOST /api/projects/{projectId}/docs/open | The project's documents in docs/, read-only: the folder tree, one Markdown or MDX file, an image, and opening the folder or a file in the computer's own app. See Project docs. |
GET /assets/{name} | Only the content-named scripts and stylesheets listed in the table generated by build.rs; others are 404. Immutable caching, gzip when Accept-Encoding allows it, Vary: Accept-Encoding. |
GET /docs-sandboxGET /docs-sandbox/{name} | The page that runs a document's MDX, and its content-named script (any other name is 404). A fixed page with no token and no board data. It has its own Content-Security-Policy: default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self', and X-Content-Type-Options: nosniff. The board embeds it in an iframe with sandbox="allow-scripts". On the board router only; the monitor has no such route. See MDX in the Docs. |
GET /api/events | SSE: ready after subscribing with the board's build ID ({"build":"<16 hex>"}), changed with the changed resources (see Change events), preferences after a saved language or theme change (the same JSON as GET /api/preferences), a keep-alive comment every 15 seconds. |
GET /api/preferences | The saved language and color theme: locale, theme (mode, accent), and revision. A missing or unreadable file gives the defaults. Never cached. See Display preferences. |
PUT /api/preferences | Saves the locale, theme, or both, from the JSON body, and returns the same shape as GET with revision raised by one. Unknown fields and invalid values are 422 invalid_preferences; a failed write is 500 preferences_unavailable. A refused or failed save leaves the saved value unchanged. |
POST /api/notify | Asks every client to re-fetch. Changes no data and needs no token. 204. |
GET /api/health | ok and pid. |
GET /api/monitor-connections and the routes under it | The ngrok mobile connection. See Mobile connection. |
Summary and Detail
The board and the monitor read a project in two steps. The Summary lists everything a card or a list row shows. The Detail streams what is read only when a card or an epic opens. The same contract is served at /monitor/api/projects/{projectId}/… for the mobile monitor, behind the monitor session; see Monitor routes. A project ID in the path selects what is read. It is not an access right.
The shapes below are fixed by the Rust types in src/server/contracts/ and by the golden examples in tests/fixtures/data-layer/, which the Rust and TypeScript tests both read.
Summary. GET …/summary returns project (id, name), version, tasks, epics, and problems. The response never holds a body, acceptance text, history, epic document, or image bytes. It is built from the projection in the index, so no file is read for it. Archived tasks are not listed (the epics count them). version is a hash of the parsed data and is also the ETag ("<version>"); a request whose If-None-Match names it (a list, a weak tag, or * also match) gets 304 with no body. A missing project is 404 project_not_found, a malformed ID is 422 invalid_value, and a project whose project.json cannot be read gets that file's error.
| Item | Fields |
|---|---|
| Task | id (integer), title, taskStatus, agentStatus, assignee, isDraft, position, epicId (or null), acceptance (done, total), acceptanceError, createdAt, updatedAt, taskStatusChangedAt, agentStatusChangedAt, pendingRequestId, pendingReason, pendingChoices, onboardingSample (only on guide samples), and detailVersions (document, history). The names match the board's Task. |
| Epic | id (string), name, color, isDraft, priority (the task IDs in the order the epic sets; empty when it sets none), priorityError (the stored order cannot be read, so priority is ignored), progress (todo, doing, done, archived, total), and detailVersions (document). |
| Problem | file, field, reason, as in the board. |
Every version is an opaque string: a content hash of the parsed data. It does not depend on the server run, so a cached value can be compared after a restart. Compare versions for equality only.
Detail. GET …/tasks/{id}/detail and GET …/epics/{epicId}/detail answer 200 with Content-Type: application/x-ndjson and Cache-Control: no-store. Each line is one JSON object whose type names the event:
type | Fields | Meaning |
|---|---|---|
start | resource (task or epic), id, projectId, sections, versions | The first line. versions names exactly the requested sections. |
section-start | section (document or history) | Opens a section. |
attachments | items | The resource's attachments. Sent once in the document section, right after section-start and before the first block. Not counted in section-end's count. |
block | kind (markdown or mermaid), text, optional images | A top-level Markdown block of the document. Joining the blocks gives the body. Each image has src (the URL used in text) and, when the file header can be read, width and height. |
acceptance | id, text, done | One acceptance condition, in full. Tasks only; it follows the blocks. |
history-item | at, reason | One history entry, oldest first. Tasks only. |
section-end | section, count | Closes the section. count is the number of item lines in it. |
complete | versions | The last line. It repeats the versions of start, both computed from one snapshot. |
The order is start, then for each requested section section-start, its items, and section-end, then complete. The document section comes before history. A task's document section holds blocks followed by acceptance conditions; an epic has only a document section. A stream that ends without complete is incomplete: the client discards what it received.
?sections=document, ?sections=history, or ?sections=document,history choose the sections; without the parameter both are sent, and an epic has no history. Any other value is 400 invalid_sections. A missing project, task, or epic is 404 before the stream starts. A failure after the first line sends no complete and closes the connection.
Change events
changed carries the changed resources:
{"instanceId": "inst-5f3a", "seq": 42, "projectId": "…", "changes": [
{"resource": "task", "id": 302, "facets": ["summary", "history"]},
{"resource": "epic", "id": "EPIC-1", "facets": ["document"]},
{"resource": "project", "id": "…", "facets": ["deleted"]}
]}| Field | Meaning |
|---|---|
instanceId | One value per server start. A new value tells a client that seq began again. |
seq | An integer that grows by one per changed within an instance. A gap means an event was missed. |
projectId | The project the changes belong to. The monitor stream carries only the requested project; the PC stream carries every project and the page ignores the others. |
changes[].resource | task, epic, or project (the project list changed: created, renamed, deleted). |
changes[].id | An integer for a task, a string for an epic and for a project. |
changes[].facets | A non-empty list without repeats. summary: title, status, position, epic, pending question, acceptance counts. document: the body and acceptance text. history: the history. deleted: the resource is gone. A project has only summary or deleted. Moving a task to the archive, or restoring it, is a summary change. |
A client that sees a gap in seq, a new instanceId, or an event it does not know drops the Summary and the related cached details. ready and preferences are unchanged.
The server compares the parsed data before and after every reload, so a save that changes nothing the Summary or a Detail shows sends no event. A file that cannot be parsed changes every facet of its resource. A stream that fell too far behind gets resync ({"instanceId", "seq"}, the newest number) instead of the events it missed, and the client fetches everything again. The monitor stream numbers the events it sends itself, so the changes of other projects leave no gap in seq, and it sends no project change.
Mobile connection
These routes exist only on the local listener (127.0.0.1); the ngrok entry never serves them. A read must come from the local page: a foreign Host, cross-site Sec-Fetch-Site, or foreign Origin is 403. The writes pass the same browser write guard as other writes.
| API | Contract |
|---|---|
GET /api/monitor-connections | The current state, described below. Never holds a secret. |
PUT /api/monitor-connections/settings/ngrok | Saves {enabled, revision} and returns the state. revision must be the one last read. Unknown fields are 422 invalid_value; a stale revision is 409 revision_conflict; a failed save is 500 save_failed and changes nothing. Saving while the tunnel runs does not replace it; only a start does. |
POST /api/monitor-connections/transports/ngrok/start | Starts the connection and answers 202 with the state (usually starting). The optional body is {token, save}. token is held in memory only; save: true also stores it in the PC's secure storage (the OS credential store) and needs a token. A disabled setting is saved as enabled first. A start while the tunnel runs replaces it and makes a new passcode. |
POST /api/monitor-connections/transports/ngrok/stop | Stops the connection, saves it as disabled, and answers 200 with the state. |
DELETE /api/monitor-connections/token | Removes the token saved in the secure storage and answers 200 with the state. Removing nothing is not an error. |
The state object has these fields:
| Field | Meaning |
|---|---|
status | disabled, needs_config, starting, ready, reconnecting, failed, or stopping. |
url | The public HTTPS address once ready; otherwise null. |
revision | The settings revision, for PUT. |
settings | enabled. |
tokenConfigured | Whether a start would find a token. |
tokenSource | env (NGROK_AUTHTOKEN), keychain (the secure storage), session (typed for this run), or null. Never the value. |
keychainAvailable | Whether this PC has a secure storage (the OS credential store) that can hold a token. |
keychainTokenSaved | Whether a token is saved in the secure storage, even if another source is used. |
failure | null, or one of connect_failed, endpoint_failed, public_url_invalid, internal, config_unreadable, config_invalid. |
passcode | The 4-digit passcode of the running connection, for the PC to show. Present only while status is ready; omitted when the connection is starting, stopped, or failed. It is the one secret this API returns. |
A start reports its progress through this state; poll GET until status leaves starting. The token, the count of wrong guesses, and sessions never appear in any response or in the settings file, and the passcode appears only here (and on the serve startup line). The settings are stored as described in Shared files. The error codes are in Error codes.
Display preferences
GET and PUT /api/preferences are on the local listener, and the ngrok monitor has the same pair at /monitor/api/preferences (see Monitor routes). The file behind them is described in Shared files.
PUT takes a JSON body with locale (en or ja), theme ({"mode": "light" | "dark", "accent": <one of the 12 accents>}), or both. A field that is not sent keeps its saved value:
{"locale": "ja", "theme": {"mode": "dark", "accent": "teal"}}A save that succeeds returns the saved preferences and sends a preferences event to every open events stream: the board's /api/events and the monitor's /monitor/api/events. The event's data is the same JSON as GET. The PC PUT passes the same write guard as the other PC writes (Host, Origin, Sec-Fetch-Site: same-origin, and X-Agboard-Token). The monitor's PUT needs the monitor session and the CSRF token, like an answer.
A malformed JSON body is 400, as for other writes. A body with neither field, a field other than locale and theme, or a value that is not allowed is 422 invalid_preferences. There is no revision check: the last save wins.
Project docs
These routes read the files in the project's docs/ folder (shared/projects/<projectId>/docs/); none of them writes it, and a PUT to the file route is 405. They are on the board router only; the monitor entry never serves them. A change to the docs on disk is announced by the SSE docs event, never by changed (see Change notifications). Paths are relative to docs/, use /, and are checked as described in Security model. A project ID that is not 32 lowercase hexadecimal digits is 422 invalid_value.
| API | Contract |
|---|---|
GET …/docs/tree | The folders and files under docs/, names only, as {nodes, truncated}. A file has viewer: markdown for .md and .mdx, image for .png, .jpg, .jpeg, .webp, .gif, .avif, and .svg, and other for any other file, which the Docs list but do not show. A folder has no viewer. Folders come first, then files, each sorted by name ignoring case. Names that start with a dot, symbolic links, and names the path rules refuse are left out. A missing folder is an empty tree, not an error. At most 5,000 entries and 16 folder levels are listed; truncated: true says that a limit cut the listing short. |
GET …/docs/file?path= | {path, content, revision}. content is the file's exact text, a BOM included. revision is sha256: and the hex SHA-256 of the file's bytes; the page compares it to tell whether a file changed. Only .md and .mdx files (any case) of at most 2 MiB that are valid UTF-8 are served. |
PUT …/docs/file | Body {path, content, expectedRevision}; unknown fields are 400 invalid_json. Writes content as its exact bytes if the file is still at expectedRevision, and returns {path, revision} with the new revision. It passes the write guard like other writes. It never creates a file. |
GET …/docs/assets?path= | The bytes of a PNG, JPEG, WebP, GIF, AVIF, or SVG image of at most 10 MiB. Content-Type comes from the extension (image/png, image/jpeg, image/webp, image/gif, image/avif, image/svg+xml) and the response carries X-Content-Type-Options: nosniff. An SVG response also carries Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox, so the file runs no script when its URL is opened on its own; the page shows it in an <img>, where script never runs. Every other type is 415 docs_asset_type. |
POST …/docs/open[?path=] | No body. On the machine that runs the server, opens the project's docs/ folder in the file manager, or, with ?path=, that file in the app the computer uses for its type: open on macOS, explorer on Windows, and xdg-open elsewhere. The path is checked as a read checks it and must name an existing file of any type (400 docs_path_invalid, 404 docs_not_found). The program is started with the folder or file as its only argument, without a shell, and the route does not wait for it to finish. Answers 204. An unknown project is 404 project_not_found. A missing docs/ folder, or a program that cannot be started (for example, xdg-open is not installed), is 500 docs_open_failed. It passes the write guard like other writes, because it starts a program. |
A tree:
{"nodes":[{"name":"design","path":"design","kind":"dir","children":[{"name":"images","path":"design/images","kind":"dir","children":[]},{"name":"spec.md","path":"design/spec.md","kind":"file","viewer":"markdown"}]},{"name":"flow.png","path":"flow.png","kind":"file","viewer":"image"},{"name":"app.pen","path":"app.pen","kind":"file","viewer":"other"},{"name":"README.md","path":"README.md","kind":"file","viewer":"markdown"}],"truncated":false}A file:
{"path":"design/spec.md","content":"---\ntitle: Design\n---\n# Design\n","revision":"sha256:b9dc9fdb…"}Failures use the usual error body. Their codes and statuses are in Error codes.
Multipart task creation
POST /api/projects/{projectId}/tasks/multipart accepts exactly one metadata part containing JSON and zero or more file parts. Each file must have a matching {id, name} entry in metadata.attachments; normal attachment count, size, and media-type limits apply. For a regular task, metadata includes requestId, title, description, assignee, and optionally isDraft. For the short request form, send:
{
"requestKind": "simple",
"description": "First line becomes the title\n\nFull request details",
"epicId": "DESIGN",
"attachments": [{"id": "<attachment-id>", "name": "sketch.png"}]
}epicId and attachments are optional; omit the epic or use null, and use an empty attachments array when there are no images. A simple request accepts no title, assignee, isDraft, or finalize metadata. The server creates an AI-owned Todo task, wraps the original text in ## 元の依頼, and derives the title from the first non-empty line (up to 100 Unicode characters). It validates and stages the task and its images under the project lock, then publishes them together by renaming the completed task directory. Every accepted submission creates a new task and returns 201 {"task": …}; the server does not compare content to detect duplicates. A normal manually created task continues to use POST /tasks or the regular multipart metadata; it does not become a simple request.
Bulk archive. The target IDs are fixed when the confirmation opens; the request never re-collects all Done tasks. Under the lock every unarchived target must still be Done. If any target moved or was deleted, the whole request is 409 archive_targets_changed and nothing changes. Targets that are already archived count as done, so a retry after a lost response never duplicates history. Targets are saved one at a time with a single reload and notification at the end; if a save fails partway, the saved targets stay archived and a retry handles the rest.
Errors carry code, message, and reason:
| Status | Meaning |
|---|---|
| 400 | Malformed JSON, an invalid sections query (invalid_sections), or an invalid docs path (docs_path_invalid). |
| 403 | A guard failed. |
| 404 | Project, task, or epic not found, or a docs file not found. |
| 409 | request_changed, task_status_changed, task_not_draft, invalid_state_for_draft, epic_in_use, epic_id_taken, task_not_archived, archive_targets_changed. |
| 413 | Body over 1 MiB, or a docs file over its limit (docs_too_large). |
| 415 | A docs file that is not Markdown, or an image type the docs do not serve (docs_not_markdown, docs_asset_type). |
| 422 | Invalid ID syntax, input, or state rule; a docs file that is not UTF-8 (docs_not_utf8); or an invalid source file (file_invalid) with path, field, and reason. |
| 503 | The lock wait timed out. |
| 500 | Anything else, including format_unsupported, file_unreadable, and file_error. |
First-run guide
| Method and path | Purpose |
|---|---|
GET /api/onboarding | The guide's state. Read only. |
POST /api/onboarding/claim | Take the guide for a tab: clientId, expectedRevision. |
POST /api/onboarding/replay | Offer a finished or skipped guide again: clientId, expectedRevision. |
POST /api/onboarding/heartbeat | Renew the lease: clientId. |
POST /api/onboarding/skip | Skip before any card is created: clientId, expectedRevision, commandId. |
POST /api/onboarding/recover | End the runs whose lease has expired. |
POST /api/projects/{projectId}/onboarding/start | Start a run: clientId, attemptId, expectedRevision, locale. A repeated attempt returns the same run. |
POST /api/projects/{projectId}/onboarding/{runId}/advance | Take one step: clientId, expectedRevision, commandId, transition. |
POST /api/projects/{projectId}/onboarding/{runId}/finish | Archive the run's cards and complete the guide. |
POST /api/projects/{projectId}/onboarding/{runId}/abort | End the run early and archive its cards. |
The writes pass the write guard, and unknown fields are refused. The answer to a sample's question uses the existing /api/projects/{projectId}/tasks/{id}/answer route.

