-
Notifications
You must be signed in to change notification settings - Fork 22
api rest
This document describes the HTTP REST API layer (v1) added alongside the MCP transport. The REST API runs on the same Ktor server when API_ENABLED=true.
Base URL: /api/v1
Content-Type: application/json (responses); see PATCH for request-body negotiation.
OpenAPI spec: api/openapi.yaml — covers all routes for machine consumption. Note: the YAML is hand-maintained and may lag behind edge-case behavior; this document and the source are authoritative.
- Authentication
- Capabilities (Authorization)
- Scope Filtering
- ETag Concurrency
- Idempotency
- Error Codes
- Pagination
- Data Transfer Objects (DTOs)
- Endpoints — Items (Read)
- Endpoints — Items (Write)
- Endpoints — Notes (Read)
- Endpoints — Notes (Write)
- Endpoints — Dependencies
- Endpoints — Transitions (Audit)
- Endpoints — Search
- Endpoints — Config / Schema Discovery
- Endpoints — Service Meta
- Server-Sent Events (SSE)
- Audit Model
- Merge Patch Semantics
- Status-Graph Caveat
- Known Limitations
All /api/v1/* endpoints require authentication. The API supports two modes, selected by API_AUTH_MODE:
Present a static token in the Authorization header:
Authorization: Bearer <token>
Tokens are defined in a YAML secret file (path: API_TOKENS_PATH, default /run/secrets/api-tokens.yaml). Each token is stored as a SHA-256 hex digest for security — the plaintext never touches disk.
Token file format (version 1):
version: 1
tokens:
- id: dashboard-reader
description: "Read-only dashboard token"
token_sha256: "e3b0c44298fc1c149afbf4c8996fb924..." # 64 lowercase hex chars
capabilities:
- read
scope:
root_ids:
- "550e8400-e29b-41d4-a716-446655440000"
expires_at: "2027-01-01T00:00:00Z" # optional ISO-8601
- id: admin-token
token_sha256: "..."
capabilities:
- admin-
id— stable identifier used in audit records (prefixed withapi:) -
token_sha256— lowercase hex SHA-256 of the plaintext token -
capabilities— list of granted operations (see §2) -
scope.root_ids— optional list of root-item UUIDs; null/empty means unrestricted -
expires_at— optional token expiry; expired tokens are rejected at lookup time - Token rotation requires a server restart (tokens are loaded once at startup).
Generating token_sha256 — the digest must cover the token's exact bytes with no trailing newline:
# macOS / Linux / Git Bash
printf '%s' "$TOKEN" | openssl dgst -sha256 | awk '{print $NF}'# Windows PowerShell
([System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($token))) -replace '-','').ToLower()Do not use
openssl dgst -sha256 <<< "$TOKEN"orecho "$TOKEN" | …—<<<andechoappend a newline, so the digest won't match the token sent in requests (every call returns401). See the quick-start REST API walkthrough for the full token-generation flow.
Present a JWT in the Authorization: Bearer header. The server validates the JWT against the JWKS endpoint configured by API_JWKS_URL. Claims extracted: iss, aud, sub, exp, nbf.
Capabilities and scope are derived from the JWT's sub claim (mapped to a principal) or from the token store if applicable — the exact mapping is deployment-specific; consult your JWKS issuer configuration.
Failing requests receive:
-
401 Unauthorized+WWW-Authenticate: Bearer error="invalid_request"— missing token -
401 Unauthorized+WWW-Authenticate: Bearer error="invalid_token"— bad/expired token -
403 Forbidden— token valid but lacks required capability
degradedModePolicy interaction (JWKS mode): When DEGRADED_MODE_POLICY=reject and JWKS verification fails, write endpoints return 401 with error verification_failed. Read endpoints and the bearer mode are unaffected (bearer auth has no JWKS chain).
Each token grants a set of additive capabilities:
| Capability | Config string | Grants access to |
|---|---|---|
READ |
read |
All GET endpoints under /api/v1/
|
WRITE_ITEMS |
write-items |
POST /items, PATCH /items/{id}, DELETE /items/{id}
|
WRITE_NOTES |
write-notes |
PUT /items/{id}/notes/{key}, DELETE /items/{id}/notes/{key}
|
ADVANCE |
advance |
POST /items/{id}/advance |
MANAGE_DEPENDENCIES |
manage-dependencies |
POST /dependencies, DELETE /dependencies/{id}
|
ADMIN |
admin |
Implies all of the above; unlocks attribution fields in responses |
ADMIN unlocks: When a caller has ADMIN, note and transition actor/verification fields are included in responses (subject to API_REDACT_* env flags). Non-admin callers always receive null for these fields.
Tokens with scope.root_ids set can only access items within those root subtrees. Scope enforcement walks the item's full ancestor chain — an item is accessible if any ancestor (including itself) is in the scope set.
-
GET /items— scope applied at SQL level viafindInScope -
GET /items/{id}—403 scope_forbiddenif item is outside scope - Write endpoints —
403 scope_forbiddenif the target item is outside scope -
GET /items/{id}/breadcrumbs— chain is truncated at the caller's scope root (ancestors above the scope root are hidden) -
GET /items/{id}/tree— paginated flat list; scope check applies on the root item only -
GET /notes/searchandGET /search—?ancestorIdis validated against the principal's scope
- Format:
"v1-<modifiedAtMillis>"(quoted string per HTTP spec) - Items: ETag is derived from
item.modifiedAt - Notes: ETag is derived from
note.modifiedAt - Returned in
ETagresponse header on all successful reads and writes
Conditional reads: If-None-Match: <etag> → 304 Not Modified when the resource has not changed.
Conditional writes:
-
PATCH /items/{id}— requiresIf-Matchheader. Missing header →400 precondition_required. Mismatch →412with erroretag_mismatch. -
DELETE /items/{id}— optionalIf-Match. When supplied and mismatched →412 etag_mismatch. -
PUT /items/{id}/notes/{key}—If-Matchaccepted on the update path (when the note already exists). Missing on update is allowed; mismatch →412 etag_mismatch. On create (note does not exist),If-Matchis ignored.
Config/schema endpoints (/config, /config/schemas, etc.) use a fingerprint-based ETag:
- Format:
"cfg-<fingerprint>"where fingerprint is derived from the config file content - Stable across reads when the config has not changed
-
If-None-Match→304when fingerprint matches
POST /items, PATCH /items/{id}, and PUT /items/{id}/notes/{key} support idempotency via the Idempotency-Key header:
Idempotency-Key: <UUID>
- Must be a valid UUID
- Malformed key →
400 bad_request - On retry with the same key: the cached response (status + body) is returned verbatim without re-executing the operation
- Cache is keyed by
(actor-id, idempotency-key); TTL is ~10 minutes - ETag pre-conditions are evaluated and stored as part of the cached response — a replay does NOT re-evaluate the ETag against the now-mutated resource
All error responses use:
{
"error": "<machine-readable-code>",
"message": "<human-readable description>",
"details": { ... } // optional structured context
}
error value |
Typical HTTP status | Description |
|---|---|---|
bad_request |
400 | Missing or malformed path/query parameter |
validation_error |
400 | Invalid field value or deserialization failure |
precondition_required |
400 |
PATCH missing required If-Match header |
not_found |
404 | Item, note, or dependency not found |
scope_forbidden |
403 | Item exists but is outside the caller's scope |
field_not_patchable |
400 | PATCH attempted on a server-owned field |
cycle_detected |
400 | Dependency would create a cycle |
unsupported_media_type |
415 | Wrong Content-Type for PATCH (see §20) |
etag_mismatch |
412 |
If-Match header does not match current ETag |
unauthenticated |
401 | No authenticated principal (missing/invalid token) |
verification_failed |
401 | JWKS verification failed under reject policy |
transition_failed |
422 | Role transition rejected (invalid trigger, gate failure, dependency blocker) |
db_error |
500 | Database query failed |
List endpoints return a PageDto<T>:
{
"items": [...],
"page": 1,
"pageSize": 20,
"totalItems": 42, // may be null when count is expensive
"hasMore": true
}Query parameters: ?page=<int> (default 1) and ?pageSize=<int> (default 20, max typically 100).
totalItems may be null for endpoints where computing an exact count is expensive; use hasMore for continuation.
{
"id": "<uuid>",
"parentId": "<uuid>|null",
"title": "string",
"description": "string|null",
"summary": "string",
"type": "string|null",
"role": "queue|work|review|terminal|blocked",
"previousRole": "queue|work|review|null",
"statusLabel": "string|null",
"priority": "HIGH|MEDIUM|LOW|CRITICAL|BACKLOG",
"complexity": 5,
"requiresVerification": false,
"tags": ["string"],
"properties": {},
"createdAt": "ISO-8601",
"modifiedAt": "ISO-8601",
"roleChangedAt": "ISO-8601",
"etag": "\"v1-<millis>\"",
"depth": 0,
"isClaimed": false,
"notes": null, // populated when ?include=notes
"children": null, // populated when ?include=children
"dependencies": null // populated when ?include=deps
}Notes on tags: The domain stores tags as a comma-separated string. The REST API expands this to a List<String> in ItemDto (GET responses). However, POST /items accepts tags as a JSON array (List<String>), while PATCH /items/{id} requires tags as a comma-separated string (e.g., "feature,auth") — this mirrors the domain storage format. Sending a JSON array in a PATCH body will result in a 400 validation_error.
{
"key": "implementation-notes",
"role": "queue|work|review",
"body": "string",
"createdAt": "ISO-8601",
"modifiedAt": "ISO-8601",
"etag": "\"v1-<millis>\"",
"actor": null, // null unless caller has ADMIN + redaction disabled
"verification": null // null unless caller has ADMIN + redaction disabled
}{
"id": "api:dashboard-editor",
"kind": "orchestrator|subagent|user|external",
"parent": "string|null",
"proof": null // null unless caller has ADMIN and ?include=proof
}For REST API writes, id is always "api:<tokenId>" and kind is always "external" (server-synthesized; client-supplied actor fields are silently dropped).
{
"status": "unverified|verified|unavailable|unchecked",
"verifier": "api-bearer|api-jwks|null",
"reason": "string|null"
}{
"id": "<uuid>",
"itemId": "<uuid>",
"fromRole": "queue|null",
"toRole": "work",
"trigger": "start",
"statusLabel": "string|null",
"occurredAt": "ISO-8601",
"actor": null, // redacted unless ADMIN
"verification": null // redacted unless ADMIN
}{
"blocks": [<DependencyEdgeDto>],
"blockedBy": [<DependencyEdgeDto>],
"related": [<DependencyEdgeDto>]
}{
"id": "<uuid>",
"fromItemId": "<uuid>",
"toItemId": "<uuid>",
"type": "blocks|relates_to",
"unblockAt": "queue|work|review|terminal|null",
"createdAt": "ISO-8601"
}{
"fromItemId": "<uuid>",
"fromTitle": "string",
"type": "blocks|relates_to"
}See §7.
See §6.
{
"itemId": "<uuid>",
"field": "title|summary|body",
"snippet": "<marked snippet>",
"score": 0.032,
"noteKey": "implementation-notes" // null for item hits, set for note hits
}NoteSchemaEntryDto:
{
"key": "implementation-notes",
"role": "work",
"required": true,
"description": "string",
"guidance": "string|null",
"skill": "string|null"
}SchemaDto:
{
"type": "feature-task",
"lifecycleMode": "auto",
"hasReviewPhase": true,
"notes": [<NoteSchemaEntryDto>],
"defaultTraits": ["needs-security-review"]
}TraitDto:
{
"name": "needs-security-review",
"notes": [<NoteSchemaEntryDto>]
}ConfigSnapshotDto:
{
"schemas": [<SchemaDto>],
"traits": [<TraitDto>],
"types": ["feature-task", "bug"],
"statusGraph": <StatusGraphDto>,
"defaultSchema": <SchemaDto>|null
}StatusGraphTypeDto:
{
"type": "feature-task",
"lifecycleMode": "auto",
"hasReviewPhase": true,
"transitions": {
"queue": {"start": "work", "complete": "terminal", "cancel": "terminal"},
"work": {"start": "review", "complete": "terminal", "block": "blocked", "hold": "blocked", "cancel": "terminal"},
"review": {"start": "terminal", "complete": "terminal", "block": "blocked", "cancel": "terminal"},
"blocked": {"resume": "<previousRole>", "complete": "terminal", "cancel": "terminal"},
"terminal": {"reopen": "queue"}
}
}Note: "<previousRole>" is a literal sentinel string — dashboards must resolve it from the live item's previousRole field.
StatusGraphDto:
{
"roles": ["queue", "work", "review", "blocked", "terminal"],
"triggers": ["start", "complete", "block", "hold", "resume", "cancel", "reopen"],
"types": [<StatusGraphTypeDto>]
}{
"id": 42,
"event": "item.updated",
"itemId": "<uuid>|null",
"modifiedAt": "ISO-8601|null",
"newRole": "work|null"
}All require READ capability.
Paginated list of work items with optional filters.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
page |
int | Page number (default 1) |
pageSize |
int | Items per page (default 20) |
role |
string | Filter by role: queue, work, review, terminal, blocked
|
priority |
string | Filter by priority: HIGH, MEDIUM, LOW, CRITICAL, BACKLOG
|
tag |
string | Comma-separated tags; all listed tags must be present (AND match) |
tagAny |
string | Comma-separated tags; any listed tag must be present (OR match). Overrides tag when both present. |
type |
string | Filter by item type |
parentId |
UUID | Filter to direct children of this parent |
rootId |
UUID | Filter to items within this root's subtree (intersected with principal scope) |
modifiedAfter |
ISO-8601 | Modified after this timestamp |
modifiedBefore |
ISO-8601 | Modified before this timestamp |
createdAfter |
ISO-8601 | Created after this timestamp |
createdBefore |
ISO-8601 | Created before this timestamp |
claimStatus |
string | Filter by claim state: claimed, unclaimed, expired
|
orderBy |
string | Sort field |
orderDir |
string | Sort direction: asc, desc
|
Response: 200 OK → PageDto<ItemDto>
Root-level items (depth=0) accessible to the caller.
- Scoped tokens: returns only the specific root items within scope (efficient, not capped)
- Unscoped/admin: scans up to 200 root items (documented cap)
Response: 200 OK → PageDto<ItemDto>
Single item by UUID.
Query parameters:
-
include— comma-separated list:notes,deps,children— inline related data
Responses:
-
200 OK→ItemDto -
304 Not Modified— whenIf-None-Matchmatches current ETag -
400 bad_request— invalid UUID 403 scope_forbidden404 not_found
Descendant tree, paginated as a flat list (root item always included as first element).
Query parameters:
-
depth— maximum relative depth from the root item (optional) - Standard pagination params
Response: 200 OK → PageDto<ItemDto>
Ancestor chain from root to the target item (inclusive). Chain is truncated at the caller's scope root for scoped tokens.
Response: 200 OK → List<ItemDto> (root-first, target last)
Direct children of an item, paginated.
Response: 200 OK → PageDto<ItemDto>
Create a work item. Requires WRITE_ITEMS.
Request body:
{
"title": "string", // required
"description": "string", // optional
"summary": "string", // optional (default empty)
"parentId": "<uuid>", // optional; null = root item
"type": "string", // optional
"priority": "HIGH", // optional (default MEDIUM)
"complexity": 5, // optional
"requiresVerification": false, // optional (default false)
"tags": ["feature", "auth"], // optional — JSON array
"statusLabel": "string", // optional
"properties": {}, // optional — JSON object
"metadata": "string" // optional
}Responses:
-
201 Created→ItemDto+ETagheader -
400 validation_error— invalid field values -
400 not_found— parentId not found -
403 scope_forbidden— parent outside scope
Supports Idempotency-Key header.
Audit: actor is synthesized as api:<tokenId> / kind external; client-supplied actor.* is dropped.
JSON Merge Patch update. Requires WRITE_ITEMS, If-Match, and Content-Type: application/merge-patch+json (or application/json).
Request body: A partial JSON object following RFC 7396 merge-patch semantics (see §20).
Tags deviation: In PATCH, tags must be a comma-separated string (not a JSON array). Example: "tags": "feature,auth". Sending a JSON array in PATCH → 400 validation_error.
Server-owned fields that cannot be patched (→ 400 field_not_patchable):
id, role, previousRole, roleChangedAt, depth, createdAt, modifiedAt, version, claimedBy, claimedAt, claimExpiresAt, originalClaimedAt
Responses:
-
200 OK→ItemDto+ETagheader -
400 precondition_required—If-Matchmissing -
400 field_not_patchable— attempted to patch server-owned field -
412 etag_mismatch—If-Matchdoes not match -
415 unsupported_media_type— wrong Content-Type +Accept-Patch: application/merge-patch+json, application/jsonresponse header
Supports Idempotency-Key header.
Cascade delete (removes item and all descendants, notes, and dependencies). Requires WRITE_ITEMS.
Optional: If-Match header — when supplied, mismatched ETag → 412 etag_mismatch.
Responses:
204 No Content404 not_found412 etag_mismatch
Trigger a role transition. Requires ADVANCE.
Request body:
{
"trigger": "start|complete|block|hold|resume|cancel|reopen"
}Claimed-item behavior: The REST API bypasses MCP claim ownership — a claimed item advances successfully even if a different MCP agent holds the claim. A WARN is emitted to the server log (API_WARN_ON_CLAIMED_ADVANCE=false to suppress). The response does NOT disclose claimedBy (tiered-disclosure principle).
Response 200 OK:
{
"itemId": "<uuid>",
"previousRole": "queue",
"newRole": "work",
"trigger": "start",
"statusLabel": "string|null"
}Responses:
-
200 OK→AdvanceResponseDto -
400 validation_error— invalid trigger string -
422 transition_failed— gate failure, dependency blocker, or invalid state transition
The hasReviewPhase is resolved from the item's schema (type + tags + traits) to match AdvanceItemTool behavior — an advance from work goes to review when the schema has a review phase, or directly to terminal when it does not.
All require READ capability. Attribution fields (actor, verification) are null by default; set to non-null only when the caller has ADMIN and API_REDACT_NOTE_ATTRIBUTION=false.
List all notes for an item.
Query parameters:
-
role— filter by phase:queue,work,review -
key— filter by note key (exact match)
Response: 200 OK → List<NoteDto>
Single note by key.
Response header: ETag: "v1-<millis>" (for use as If-Match on subsequent PUT)
Responses:
-
200 OK→NoteDto -
404 not_found— note not found on item
All require WRITE_NOTES capability.
Upsert (create or replace) a note. role and body are always replaced on update.
Request body:
{
"role": "queue|work|review", // required
"body": "string", // required
"properties": {} // optional — reserved, currently ignored
}If-Match behavior: Optional on create. On update (note already exists), supplied If-Match is validated; mismatch → 412 etag_mismatch.
Responses:
-
201 Created→NoteDto+ETagheader (note was new) -
200 OK→NoteDto+ETagheader (note was updated) 412 etag_mismatch
Supports Idempotency-Key header.
Delete a note.
Responses:
204 No Content-
404 not_found— note not found
Returns the combined dependency view split by direction and type. Requires READ.
Response 200 OK: DependenciesDto with blocks, blockedBy, and related arrays.
Items that reference this item via any dependency edge. Requires READ.
Response 200 OK: List<BacklinkDto>
Create a dependency edge. Requires MANAGE_DEPENDENCIES.
Request body: DependencyCreateDto
{
"fromItemId": "<uuid>",
"toItemId": "<uuid>",
"type": "blocks|relates_to",
"unblockAt": "queue|work|review|terminal|null"
}Validation:
-
fromItemId≠toItemId—400 validation_error -
unblockAtmust be absent or null forrelates_toedges —400 validation_error - Both items must exist —
400 not_found - Both items must be in scope —
403 scope_forbidden - Cycle detection —
400 cycle_detected
Response: 201 Created → DependencyEdgeDto
Remove a dependency edge by its UUID. Requires MANAGE_DEPENDENCIES.
Scope check: both fromItemId and toItemId of the edge must be accessible to the caller.
Responses:
204 No Content404 not_found403 scope_forbidden
All require READ. actor and verification fields are redacted (null) for non-admin callers; admin callers see them subject to API_REDACT_NOTE_ATTRIBUTION and API_REDACT_ACTOR_PROOF env vars. Proof requires ADMIN + ?include=proof.
Per-item role-transition history (append-only audit log), paginated.
Response: 200 OK → PageDto<RoleTransitionDto>
Recent transitions across all items. Default window: last 24 hours.
Query parameters:
-
since— ISO-8601 timestamp; default: 24 hours ago - Standard pagination params
Scope-filtered: scoped tokens only see transitions for items within their scope (ancestor-chain check).
Response: 200 OK → PageDto<RoleTransitionDto>
All require READ.
FTS5 full-text search over item titles and summaries.
Query parameters:
-
q(required) — search query; special characters are auto-sanitized -
ancestorId— scope results to a subtree -
role— filter by item role -
tag— comma-separated tag filter
Results are ranked by RRF-fused relevance (trigram + porter tokenizer). Returns up to 50 hits.
Response: 200 OK → List<SearchHitDto>
H2 caveat: Returns an empty list in test environments where the repository is H2-backed (FTS5 requires SQLite).
FTS5 full-text search over note bodies.
Query parameters:
-
q(required) — search query -
ancestorId— scope results to a subtree
Returns up to 50 hits. noteKey is populated on every hit (note-body search always has a key).
Response: 200 OK → List<SearchHitDto>
All require READ. All config endpoints emit a fingerprint-based ETag ("cfg-<fingerprint>") and support If-None-Match → 304 Not Modified.
Full config snapshot: all schemas, traits, types, and the status-transition graph.
Response: 200 OK → ConfigSnapshotDto
All schema definitions.
Response: 200 OK → List<SchemaDto>
Single schema by type name.
Responses:
-
200 OK→SchemaDto -
404 not_found— unknown type
All trait definitions.
Response: 200 OK → List<TraitDto>
Sorted list of registered type name strings.
Response: 200 OK → List<String>
Structural role-transition graph across all schema types.
Response: 200 OK → StatusGraphDto
See §21 for the important caveat about what the status graph does and does not reflect.
Requires READ. Returns server metadata and the caller's resolved capabilities.
Response 200 OK:
{
"serverName": "mcp-task-orchestrator-current",
"version": "3.8.0",
"apiVersion": "v1",
"capabilities": ["read", "write-items"],
"claimModeAvailable": true,
"actorAuthenticationEnabled": false
}No authentication required. Lightweight DB probe.
Responses:
-
200 OK→{"status": "ok", "dbReachable": true} -
503 Service Unavailable→{"status": "degraded", "dbReachable": false}
No authentication required. Service discovery document. Mounted at the root (not under /api/v1).
Response 200 OK:
{
"name": "mcp-task-orchestrator-current",
"version": "3.8.0",
"apiVersion": "v1",
"apiUrl": "/api/v1"
}Real-time event stream. Requires READ or ADMIN capability.
Authentication — pre-flight plugin (important):
Ktor's sse {} handler runs inside the response-body phase — after the HTTP 200 status is committed. Auth cannot be performed inside the handler itself. The SSE route uses a dedicated pre-flight plugin that checks authentication in the Plugins phase, before streaming begins. A failed auth check sends 401/403 before any SSE content is produced.
Auth resolution order:
-
Authorization: Bearer <token>header — always accepted -
?token=<plaintext>query parameter — only accepted whenAPI_ALLOW_QUERY_TOKEN_FOR_SSE=true - If neither present →
401
Browser SSE note: The native browser EventSource API cannot set custom headers. Browsers must use a fetch-based SSE client (e.g., @microsoft/fetch-event-source) to provide the Authorization: Bearer header, or enable API_ALLOW_QUERY_TOKEN_FOR_SSE=true to use the query-parameter path.
Query parameters:
-
root(repeatable) — filter to events for items in this root's subtree. Effective subscription = intersection of?root=values withprincipal.scope.rootIds. -
types— comma-separated event type filter (e.g.,types=item.created,item.advanced)
Last-Event-ID replay: The bus maintains a ring buffer of recent events (size: API_SSE_BUFFER_SIZE, default 1000). On reconnect, events with id > Last-Event-ID are replayed before live streaming resumes.
KNOWN LIMITATION — replay is unfiltered by root: Ring-buffer entries do not carry the per-publisher root metadata used for live filtering. A reconnecting client with ?root=<uuid> may receive buffered event metadata from other roots during replay. Only the live stream path is correctly root-filtered. Clients should treat replayed events as potentially broader than their live subscription filter and re-fetch full item state when needed.
Event ID namespace: The monotonic ID counter for /api/v1/events is independent from the /mcp SSE channel's EventStore. Do NOT reuse Last-Event-ID values across the two channels.
Token expiry: The SSE handler periodically checks token expiry (interval: API_SSE_AUTH_CHECK_INTERVAL_SECONDS, default 30s). When a token expires, an auth.expired event is sent and the stream closes. The client must reconnect with a fresh token.
| Event type | itemId |
modifiedAt |
newRole |
Description |
|---|---|---|---|---|
item.created |
set | set | null | Work item created |
item.updated |
set | set | null | Work item field updated |
item.deleted |
set | set | null | Work item deleted |
item.advanced |
set | set | set | Role transition occurred; newRole is the target role |
note.upserted |
set | set | null | Note created or updated |
note.deleted |
set | set | null | Note deleted |
dependency.added |
set | set | null | Dependency edge created |
dependency.removed |
set | set | null | Dependency edge removed |
scope.entered |
set | set | null | Item reparented into this root's subtree |
scope.left |
set | set | null | Item reparented out of this root's subtree |
sync.lost |
null | null | null | Client's per-connection queue overflowed; re-fetch full state |
auth.expired |
null | null | null | Connection's token has expired; reconnect with fresh credential |
item.advanced note: This event is emitted on role change (via POST /items/{id}/advance or any write path that triggers RoleTransitionHandler). It carries the newRole field. This is distinct from item.updated — a role change emits item.advanced (not item.updated).
All write endpoints (POST, PATCH, PUT, DELETE) synthesize an actor server-side from the authenticated principal. Client-supplied actor.* fields in the request body are silently dropped — callers cannot override audit attribution.
Synthesized actor:
-
id="api:<tokenId>"(e.g.,"api:dashboard-editor") -
kind="external"(distinguishes API writes from MCP agent writes) -
parent=null -
proof=null(bearer token is in the HTTP header; it is not echoed into stored audit records)
Attribution redaction (applies to notes and transitions):
- Non-admin callers:
actorandverificationfields arenullin responses - Admin callers: fields are visible subject to
API_REDACT_NOTE_ATTRIBUTIONandAPI_REDACT_ACTOR_PROOFenv vars -
proofwithinactor: requiresADMINcapability AND?include=proofin the request
Redaction env vars:
-
API_REDACT_NOTE_ATTRIBUTION(defaulttrue) — whentrue, non-admin callers see no attribution -
API_REDACT_ACTOR_PROOF(defaulttrue) — whentrue,proofis redacted even from admin callers unless?include=proof
PATCH /items/{id} implements RFC 7396 JSON Merge Patch.
Rules:
- Fields present in the patch object with a non-null value → replace that field
- Fields absent from the patch object → unchanged
-
nullvalue in the patch → delete that field (clears to null/empty) - Nested objects merge recursively; arrays replace wholesale
Content-Type: Must be application/merge-patch+json or application/json. Wrong type → 415 with Accept-Patch response header.
Patchable fields: title, description, summary, statusLabel, priority, complexity, requiresVerification, tags (CSV string), type, properties, metadata, parentId
properties null-delete/nested-merge: The properties field in the patch is merged recursively when both the base and the patch value are JSON objects. Setting a key to null removes it from properties. Setting properties itself to null clears the entire properties object.
tags deviation — see §8 ItemDto section. POST accepts a JSON array; PATCH requires a CSV string.
GET /config/status-graph returns the structural (schema-defined) transition graph — which triggers are valid for each role per type.
This graph does NOT reflect:
- Runtime gate enforcement (note gates — required notes that must be filled before
startsucceeds) - Dependency blockers (an item blocked by an unsatisfied dependency cannot advance)
- Claim ownership (MCP callers without claim ownership are blocked; REST API callers bypass claim ownership)
- Per-item lifecycle exceptions
Dashboard UI: do not use the status graph to pre-compute which buttons to enable. Always call POST /items/{id}/advance and surface the 422 transition_failed error if the transition is blocked at runtime.
The "<previousRole>" sentinel in blocked.resume is a literal string — resolve it from the live item's previousRole field.
SSE Last-Event-ID replay is unfiltered by root. When a client reconnects with ?root=<uuid> and a Last-Event-ID, buffered events replayed from the ring buffer are not filtered by root. The client may receive metadata for events from other roots during replay. The live stream path is correctly filtered. This is a known limitation of the current ring-buffer design; root metadata is not stored with buffered events. Mitigation: after reconnecting, re-fetch the full item list to reconcile state rather than relying solely on replayed events.
SSE is bearer-mode only. The pre-flight auth plugin for the SSE route only supports bearer token authentication. JWKS JWT authentication for SSE is not implemented (the pre-flight plugin does not invoke the JWKS verifier).
GET /items/roots unscoped cap. Unscoped/admin callers on GET /items/roots receive at most 200 root items. Scoped callers are not subject to this cap.
FTS5 requires SQLite. Search endpoints (GET /search, GET /notes/search) return empty results when the repository is H2-backed (test/embedded environments). FTS5 is only available against the production SQLite database.
Getting Started
Integration Guides
- Overview
- Bare MCP
- CLAUDE.md-Driven
- Note Schemas
- Plugin: Skills & Hooks
- Output Styles
- Self-Improving Workflow
Reference
Operations
Project