Skip to content

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 ​

APIContract
GET /HTML with the write token in meta[name=agboard-token].
GET /api/projectsprojects (id, name, createdAt) and catalogVersion, without tasks. 200 even when empty.
POST /api/projects/{projectId}/tasksCreates a Todo/Idle task from title, body, acceptance, assignee, isDraft. The server composes and validates the frontmatter. 201.
POST /api/projects/{projectId}/tasks/multipartCreates 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}/draftSets 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}/movetaskStatus, 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}/answerrequestId, 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-statusChanges 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}/epicsList 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}/restoreNo 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}/archiveArchived 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}/summaryThe 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}/detailOne task's body, acceptance conditions, and history as an NDJSON stream. Archived tasks are read the same way.
GET /api/projects/{projectId}/epics/{epicId}/detailOne epic's document as an NDJSON stream.
GET /api/projects/{projectId}/docs/tree
GET /api/projects/{projectId}/docs/file
GET /api/projects/{projectId}/docs/assets
POST /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-sandbox
GET /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/eventsSSE: 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/preferencesThe 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/preferencesSaves 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/notifyAsks every client to re-fetch. Changes no data and needs no token. 204.
GET /api/healthok and pid.
GET /api/monitor-connections and the routes under itThe 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.

ItemFields
Taskid (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.
Epicid (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).
Problemfile, 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:

typeFieldsMeaning
startresource (task or epic), id, projectId, sections, versionsThe first line. versions names exactly the requested sections.
section-startsection (document or history)Opens a section.
attachmentsitemsThe 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.
blockkind (markdown or mermaid), text, optional imagesA 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.
acceptanceid, text, doneOne acceptance condition, in full. Tasks only; it follows the blocks.
history-itemat, reasonOne history entry, oldest first. Tasks only.
section-endsection, countCloses the section. count is the number of item lines in it.
completeversionsThe 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:

json
{"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"]}
]}
FieldMeaning
instanceIdOne value per server start. A new value tells a client that seq began again.
seqAn integer that grows by one per changed within an instance. A gap means an event was missed.
projectIdThe 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[].resourcetask, epic, or project (the project list changed: created, renamed, deleted).
changes[].idAn integer for a task, a string for an epic and for a project.
changes[].facetsA 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.

APIContract
GET /api/monitor-connectionsThe current state, described below. Never holds a secret.
PUT /api/monitor-connections/settings/ngrokSaves {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/startStarts 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/stopStops the connection, saves it as disabled, and answers 200 with the state.
DELETE /api/monitor-connections/tokenRemoves 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:

FieldMeaning
statusdisabled, needs_config, starting, ready, reconnecting, failed, or stopping.
urlThe public HTTPS address once ready; otherwise null.
revisionThe settings revision, for PUT.
settingsenabled.
tokenConfiguredWhether a start would find a token.
tokenSourceenv (NGROK_AUTHTOKEN), keychain (the secure storage), session (typed for this run), or null. Never the value.
keychainAvailableWhether this PC has a secure storage (the OS credential store) that can hold a token.
keychainTokenSavedWhether a token is saved in the secure storage, even if another source is used.
failurenull, or one of connect_failed, endpoint_failed, public_url_invalid, internal, config_unreadable, config_invalid.
passcodeThe 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:

json
{"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.

APIContract
GET …/docs/treeThe 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/fileBody {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:

json
{"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:

json
{"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:

json
{
  "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:

StatusMeaning
400Malformed JSON, an invalid sections query (invalid_sections), or an invalid docs path (docs_path_invalid).
403A guard failed.
404Project, task, or epic not found, or a docs file not found.
409request_changed, task_status_changed, task_not_draft, invalid_state_for_draft, epic_in_use, epic_id_taken, task_not_archived, archive_targets_changed.
413Body over 1 MiB, or a docs file over its limit (docs_too_large).
415A docs file that is not Markdown, or an image type the docs do not serve (docs_not_markdown, docs_asset_type).
422Invalid 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.
503The lock wait timed out.
500Anything else, including format_unsupported, file_unreadable, and file_error.

First-run guide ​

Method and pathPurpose
GET /api/onboardingThe guide's state. Read only.
POST /api/onboarding/claimTake the guide for a tab: clientId, expectedRevision.
POST /api/onboarding/replayOffer a finished or skipped guide again: clientId, expectedRevision.
POST /api/onboarding/heartbeatRenew the lease: clientId.
POST /api/onboarding/skipSkip before any card is created: clientId, expectedRevision, commandId.
POST /api/onboarding/recoverEnd the runs whose lease has expired.
POST /api/projects/{projectId}/onboarding/startStart a run: clientId, attemptId, expectedRevision, locale. A repeated attempt returns the same run.
POST /api/projects/{projectId}/onboarding/{runId}/advanceTake one step: clientId, expectedRevision, commandId, transition.
POST /api/projects/{projectId}/onboarding/{runId}/finishArchive the run's cards and complete the guide.
POST /api/projects/{projectId}/onboarding/{runId}/abortEnd 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.