HTTP API
workfile ui starts the local server (default http://127.0.0.1:4747). The same
core services back the CLI, the MCP server and the UI — the API is a thin layer.
Conventions
- Managed record reads expose an
ETag; mutations acceptIf-Matchand reject stale writes with a conflict error. - Errors use stable codes:
{
"error": {
"code": "MEMORY_WRITE_CONFLICT",
"message": "The memory record changed after it was loaded.",
"details": {}
}
}
- List endpoints accept
q,limitandoffset; responses carrytotal. - List endpoints also accept
view=full|summary|listandfields=a,b,c.summaryreplaces the Markdown body withbodyBytesand a 200-characterexcerptand reduces the link arrays to ids and relations;listdrops the excerpt too.fieldsoverrides the view and returns exactly those keys. Measured on 100 records: 169 KB full, 70 KB summary, 43 KB list, 6.7 KB for three fields.fullis the default deliberately — the packaged UI still renders record bodies out of its list responses — so narrowing is opt-in until it fetches what it displays. - Collection reads carry an
ETagover the page they return, and honourIf-None-Matchwith a304. Cards are the corpus a polling client re-fetches most, so this is where it matters: the steady state costs a header exchange. - JSON responses above ~1.4 KB are gzipped when
Accept-Encodingallows it, withVary: Accept-Encoding. Brotli is not offered: ~14% smaller for roughly an order of magnitude more CPU on a single-threaded server. Cache-Controlisno-cache— revalidate every time, but a revalidation may answer304. It is deliberately notno-store, which would forbid that.
Events
GET /api/v2/events is a Server-Sent Events stream of workspace changes.
event: hello
data: {"serverId":"aec9c77abfc871ec","lastEventId":0}
id: 1
event: records.changed
data: {"epoch":1,"count":1,"paths":[".project/cards/T-0042-example.md"]}
| Event | Meaning |
|---|---|
hello |
Sent on connect. serverId distinguishes a reconnection to the same process from one to a restarted process whose ids began again. |
records.changed |
Files changed. Carries the paths and the new index epoch. |
activity.changed |
A card write may have changed who is working on what. A separate event so a presence view need not refetch records. |
sync.reset |
Too many paths at once (a git checkout, a release), or the client's Last-Event-ID fell off the ring buffer. Refetch rather than applying a delta. |
Events are invalidations, not payloads: no record body ever travels down the channel. The client fetches what the view it has mounted actually needs.
The source is a file watcher over the protocol corpus, so it sees every writer —
the CLI, an agent over MCP, git, an editor — not only mutations made through
this server. .project/.cache is excluded: it holds the locks that churn on
every write, the persisted index and agent activity, so watching it would feed
back into itself.
EventSource reconnects on its own and resumes with Last-Event-ID. The
watcher is a fast path and not the source of truth — fs.watch is silent on
network filesystems and its queue is bounded — so the index still revalidates
against the filesystem. A dropped event costs latency, never correctness.
Diagnostics
GET /api/v2/metrics reports request counts per route, p50/p95 latency over the
last thousand requests, the index epoch, connected event clients and the
watcher's mode. workfile ui --verbose (or PROJECT_LOG=1) also writes one line
per request to stderr, and any 5xx logs its stack — which nothing did before, so
an error shown in the interface had no counterpart anywhere to diagnose it from.
Activity
GET /api/v2/activity answers who is working on what, combining three signals
that already existed and that nothing joined up:
- the lock files
withFileLockwrites, which exist exactly as long as a write does — the most precise "right now" the system has; - the durable claims in card frontmatter (
claimed_by,claimed_at,scope); - session heartbeats under
.project/.cache/activity/sessions/.
Each claim carries a derived state: live (a session is signalling),
held, stale (past cards.claimLeaseHours) or orphaned (a session that
stopped signalling). That distinction is the point — a claim from four minutes
ago and one from a process that died three days ago looked identical before.
conflicts lists claims by different actors whose scopes overlap. This is the
situation claims exist to prevent, and it was computed inside claimCard and
then thrown away with the response.
Request guard
The server holds unauthenticated read and write access to the repository, so the browser's own origin rules are the entire security model. Every request is checked before routing:
| Condition | Response |
|---|---|
Host outside the allowlist, or its port is not the listening port |
403 REQUEST_HOST_FORBIDDEN |
Sec-Fetch-Site present and not same-origin / none |
403 REQUEST_ORIGIN_FORBIDDEN |
Origin present and outside the allowlist |
403 REQUEST_ORIGIN_FORBIDDEN |
POST/PUT/PATCH/DELETE with a CORS-simple or missing Content-Type |
415 REQUEST_CONTENT_TYPE_INVALID |
The allowlist is 127.0.0.1, localhost and ::1, plus the bind address when
--host names a specific non-wildcard interface.
The last rule matters as much as the others: text/plain,
application/x-www-form-urlencoded, multipart/form-data and no content type
at all are CORS-simple, so a cross-origin page could send them without a
preflight. Requiring anything else forces a preflight, which this server never
answers.
Practical consequence for clients: mutations must set an explicit
Content-Type. Use application/json for the JSON API and
application/octet-stream (or any concrete binary type) for asset uploads. A
fetch that passes a File or an ArrayBuffer without setting the header will
be refused.
Non-browser clients are unaffected — curl sends no Origin and no
Sec-Fetch-Site, and its Host is the loopback address it dialled.
Assets are served with X-Content-Type-Options: nosniff, a
default-src 'none'; sandbox CSP, and Content-Disposition: attachment for
anything outside a narrow inline allowlist. Uploads of types that can execute
script (.html, .svg, .mjs, …) are refused with
400 ASSET_TYPE_NOT_ALLOWED.
Workspace and index
GET /api/v2/workspace
GET /api/v2/schema
GET /api/v2/health
GET /api/v2/update
GET /api/v2/records?q=&kind=&limit=&offset=
GET /api/v2/search?q=&kind=&limit=&offset=&mode=
GET /api/v2/records/:id
/update answers whether a newer @illodev/workfile is published:
{ status, installed, latest, checkedAt, nextCheckAt, source } with status
one of behind, current, ahead, unknown (no network, or no version in
the answer) or disabled (upgrade.check: false). It is the one route that
can reach outside the machine — a single GET to the npm registry, cached for
24 hours under .project/.cache — and the footer calls it once per page load.
The security model states exactly what is sent.
Search responses carry mode ("lexical", "hybrid" or "regex") and
provider (the semantic provider's id, else null), so a client can show
which search actually ran.
/search consults the semantic provider declared in project.config.mjs
(when there is one) and returns mode: "hybrid" with per-record
semanticScore; ?mode=lexical opts out. /records is always lexical. A q
of the full /pattern/flags form (flags from imsu) runs as a regular
expression over id, title and body, bypasses the provider and returns
mode: "regex"; an invalid pattern is 400 SEARCH_REGEX_INVALID.
Work
GET/POST /api/v2/cards
PATCH /api/v2/cards/:id
POST /api/v2/cards/:id/claim
POST /api/v2/cards/:id/transition
POST /api/v2/cards/:id/archive
POST /api/v2/cards/:id/reopen
POST /api/v2/cards/bulk
PATCH /api/v2/cards/:id, POST /api/v2/cards/:id/transition and
POST /api/v2/cards/bulk accept method, run and evidence beside actor,
force and reason. They describe the write rather than the card, so they are
lifted out of the flat body the same way force is, and a client that sends
{"status": "done", "method": "ci", "run": "https://…"} gets a card whose
verified block says so. Sending any of them on a write that does not move the
card into done is 400 CARD_VERIFICATION_NOT_APPLICABLE rather than a silent
drop; method: "forced" is 400 CARD_VERIFICATION_METHOD_CONFLICT, since it is
derived from what force waived. The legacy PATCH /api/tasks/:id accepts the
same three.
A method the card's area does not accept is 409 CARD_VERIFICATION_METHOD_REFUSED,
and the body's details carry the accepted list. Omitting method is not a way
around it — a close with none records local, which is judged like any other.
GET /api/v2/schema reports the policy under cards.verification.methods, so a
client can read it before it writes. force with a reason waives it, and the
card then records forced.
Docs
GET/POST /api/v2/docs
GET/PATCH /api/v2/docs/:id
History
GET/POST /api/v2/changelog
GET/PATCH /api/v2/changelog/:id
POST /api/v2/changelog/releases/preview
POST /api/v2/changelog/releases
GET/POST /api/v2/changelog/render
Memory
GET/POST /api/v2/memory
GET/PATCH /api/v2/memory/:id
POST /api/v2/memory/:id/graduate
POST /api/v2/memory/:id/supersede
Agents and CI
GET /api/v2/agents
POST /api/v2/agents/sync
GET /api/v2/agents/context?card=T-0001
GET /api/v2/ci
POST /api/v2/ci/sync
MCP inspection
GET /api/v2/mcp
GET /api/v2/mcp/config
Legacy routes
/api/tasks, /api/health and /api/knowledge remain for existing callers.
The packaged UI no longer uses the first two: it boots from
/api/v2/workspace (identity plus the runtime schema) and reads
/api/v2/cards, which — unlike /api/tasks — honours q, limit, offset
and view, and carries an ETag.
Asset upload is still POST /api/tasks/:id/assets; it has no v2 equivalent yet.
New integrations should target /api/v2/*.