// docs/reference/api.md docs online

HTTP API

The daemon serves REST + SSE on loopback. Every client — the TUI, the vincent subcommands, your script — uses this and nothing else.


Transport and auth

  • HTTP/1.1 + JSON on 127.0.0.1 only. No TLS — it is loopback.
  • Every request needs Authorization: Bearer {token}, where the token is the contents of {data_dir}/token (created 0600). This is what keeps other local users and drive-by browser requests out; CORS is additionally disabled.
  • Discovery: read {data_dir}/daemon.json for the port, then GET /v1/health.
  • Versioning: path-prefixed (/v1), additive changes only within a version.
DATA_DIR=${VINCENT_DATA_DIR:-$HOME/.local/share/vincent}
PORT=$(jq -r .port "$DATA_DIR/daemon.json")
TOKEN=$(cat "$DATA_DIR/token")
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:$PORT/v1/info" | jq

Errors

{ "error": { "code": "invalid_state",
             "message": "task 7 is running, not queued",
             "details": { "state": "running" } } }

Codes are stable snake_case strings; HTTP status codes are used properly. details is optional and carries values a client should branch on rather than parse out of prose — an invalid state transition is always 409 with details.state set to the state actually found. It is omitted when empty.

Request bodies

Three rules apply to every request body, before the endpoint sees it.

One JSON document. A body is one JSON value followed only by whitespace. Two concatenated documents — a retry that rewrites the body, a jq -c loop piped into one curl -d @- — are 400 invalid_json. Nothing after the first document is ever acted on, and nothing is silently discarded.

Bounded. Bodies are read up to a fixed limit and no further. Over it is 413 payload_too_large with a message naming the limit; the body is never echoed back.

Limit Bytes Applies to
Ordinary request body 64 KiB every route not listed below
Large request body 4 MiB POST /v1/tasks, POST /v1/resolve, POST /v1/tasks/{id}/retry, /repair, /answer, POST /v1/workflows/validate — the bodies that carry a prompt or a workflow source
yaml in POST /v1/workflows/validate 1 MiB the same bound a workflow file gets when the registry loads it

Individual fields are bounded too — over one is 400 validation_failed naming the field and the limit:

Field Bytes / count
title 1 KiB
description 64 KiB
project name, branch_name, base_branch, branch_override 512 B
prompt, prompt_override 1 MiB
run_override 16 KiB
one fields key 256 B
one answers key 64 KiB
one fields / answers value 64 KiB
fields / answers entries, values per answer 100

The two keys differ because they are different kinds of thing. A fields key is a short identifier you choose. An answers key is not yours to choose at all: it is the agent’s question text, which the answer is keyed by and which the daemon hands back to the CLI unchanged, so it is bounded like the prose it is.

These are fixed constants, not configuration: a body larger than one of them is a buggy client rather than a workload to tune for.

Labelled JSON, leniently. Send Content-Type: application/json. A body with no Content-Type is accepted (so curl --data-binary @file.json with no header still works), as is any */json or *+json type with any parameters. A non-empty body labelled something clearly not JSON — text/html, or the application/x-www-form-urlencoded that a plain curl -d sends without -H — is 415 unsupported_media_type.

The server also bounds how long a request may take to arrive (read-header, whole request, and idle-connection timeouts). Responses are not bounded: SSE streams are long-lived by contract and no write deadline is set.


Daemon

Method Path Notes
GET /v1/health Liveness → { status, version }. Unauthenticated
GET /v1/info Version, uptime, agent availability, caps in effect, orphans, and the database’s byte footprint
GET /v1/config The effective global config, read-only — including the tui section the daemon only relays
GET /v1/agents Per-adapter availability plus model/effort options. ?refresh=true forces a re-probe
GET /v1/doctor The whole diagnostic report. Read-only. ?probe=false skips the forced adapter re-probe — see Doctor
POST /v1/doctor/fix Removes orphaned worktrees and compacts the database
POST /v1/daemon/stop Graceful shutdown → 202, then the daemon exits
POST /v1/daemon/backup { path } — writes a .tar.gz of daemon state to path. See Backup
GET /v1/maintenance/orphans Directories under the data dir no task claims, with sizes. Removes nothing
POST /v1/maintenance/gc { force?, dry_run? } — reclaims them. Same body shape as the list

GET /v1/agents is the option catalog the TUI’s pickers render:

{ "agents": [ {
    "name": "claude", "available": true, "path": "…", "version": "2.1.224",
    "supports_input": true, "input_verdict": "supported", "logged_in": null,
    "models":  [ { "value": "sonnet", "source": "cli" } ],
    "efforts": [ { "value": "max",    "source": "cli" } ],
    "default_model": "", "default_effort": "",
    "probed_at": "2026-08-07T10:00:00Z", "probe_error": null,
    "quota": null } ] }

source is provenance: cli was discovered from the installed binary, curated comes from vincent’s own floor. Results are cached by binary identity (path + mtime + version), so upgrading a CLI invalidates the cache by construction. logged_in is null where the adapter has no cheap authentication probe (claude, whose CLI exposes no non-interactive auth surface) and a definite boolean where it does (codex via login status, cursor via status) — because an installed-but-unauthenticated CLI probes as healthy and then fails every run. It is never guessed: a probe that times out or cannot be spawned reports null, not false.

Backup

POST /v1/daemon/backup writes one archive of everything the daemon owns and answers with what it wrote:

{ "path": "/home/you/vincent-2026-08-25.tar.gz",
  "bytes": 1503238553,
  "database_bytes": 8601600,
  "transcript_bytes": 1494360064,
  "schema_version": 13,
  "created_at": "2026-08-25T14:05:00.000000000Z" }
  • path must be absolute and must not exist. The daemon resolves it against its own working directory, not the caller’s, and it never overwrites a file that is by construction somebody’s backup. A path inside {data_dir}/transcripts is refused too — the archive would read itself. Every one of these is a 400 validation_failed.
  • The daemon assembles the whole archive: the database copy and the two directory trees. That keeps exactly one process walking daemon-owned state, and it is why there is no cold-copy mode — only the daemon opens SQLite.
  • The database copy is VACUUM INTO, which runs in a read transaction, so a backup may be taken while tasks are running. It costs the store’s single connection for the duration of the copy: other queries queue behind it, bounded by the size of the database.
  • The archive layout, what it excludes, and the restore rules are in Files. There is no restore endpoint — vincent daemon restore runs client-side, because the daemon it would overwrite has to be down.

Usage quota

quota is what the daemon has watched happen to that adapter’s usage window. It is an observation, not a measurement: none of claude, codex or cursor can report remaining quota from a non-interactive invocation, so vincent reports the usage_limit stops it has seen for itself rather than a number nothing can produce.

"quota": {
  "spent": true,
  "used_percent": null,
  "window": null,
  "observed_at": "2026-08-24T14:05:00Z",
  "resets_at": "2026-08-24T14:20:00Z",
  "resets_at_reported": true,
  "source": "observed"
}
  • null — never a zeroed block — means nothing has ever been observed for that adapter, which is the normal state. A zero would read as “empty quota”.
  • spent is derived per request (now < resets_at). A window that has reset does not delete the observation: spent: false with observed_at and resets_at intact is how “this adapter ran out at 14:05 and has since recovered” is said.
  • resets_at_reported separates a fact from an estimate. true means the CLI named the reset; false means usage_limit_recheck_interval supplied it, and a client must not render a computed guess as something the CLI stated.
  • used_percent and window are permanently null. They are declared so a client written against this shape keeps working the day a vendor ships a quota surface, at which point they fill in and source changes.
  • source is observed for everything written today.
  • An observation is retired by evidence: the next successful agent step on that adapter deletes it. Nothing sweeps it on a timer.

The same block rides GET /v1/info per adapter, so a client rendering a badge from /v1/info needs no second fetch. Both are served from one read, so the two endpoints can never disagree. Changes are announced by the agent.quota_changed event.

Doctor

GET /v1/doctor is one read-only body carrying every group vincent doctor renders:

{
  "generated_at": "2026-08-15T10:00:00Z",
  "paths":    { "config_dir": "…", "data_dir": "…", "config_file": "…",
                "config_file_exists": true, "config_parses": true,
                "config_permissions": [] },
  "daemon":   { "status": "running", "pid": 4021, "port": 51234,
                "started_at": "2026-08-15T09:00:00Z", "uptime_seconds": 3600,
                "version": "0.1.1" },
  "log":      { "path": "…", "exists": true, "size_bytes": 18244,
                "mod_time": "…", "tail": ["…"] },
  "database": { "path": "…", "known": true, "size_bytes": 262144,
                "wal_bytes": 4136960, "shm_bytes": 32768,
                "total_bytes": 4431872,
                "schema_version": 13, "newest_migration": 13,
                "integrity_check": "ok",
                "table_rows": { "events": 91234, "step_runs": 812, "tasks": 140,
                                "projects": 7, "agent_quota": 0,
                                "schema_migrations": 13 },
                "oldest_event_at": "2025-06-02T09:11:04Z",
                "workflow_snapshot_bytes": 2179072 },
  "agents":   [ { "name": "codex", "available": true, "path": "…",
                  "version": "0.147.0", "logged_in": true } ],
  "storage":  { "worktrees_dir": "…", "disk_free_bytes": 127310651392,
                "disk_total_bytes": 494384795648,
                "worktree_count": 3, "worktree_bytes": 8412736,
                "orphans_known": true, "orphans": [] },
  "tasks":    { "known": true, "total": 14,
                "counts": { "queued": 1, "running": 1, "blocked": 12, "…": 0 },
                "unreconciled": [] },
  "problems": []
}
  • problems[] is the daemon’s verdict, not something a client re-derives: it is the closed set that makes the CLI exit 1 (config that does not parse, an unresponsive daemon, a failed integrity_check, a schema newer than the binary, orphaned worktrees, or an unreconciled task). A missing or logged-out agent CLI and any number of blocked tasks are reported and never appear here.
  • paths.config_permissions[] is a warning, not a verdict. Each entry is a config path whose mode grants group or other access — { "path", "mode", "expected_mode", "remediation" }, where remediation is the exact chmod. It never reaches problems[] and never changes the CLI’s exit code: the daemon re-tightens both paths on every start, so an entry means no daemon has started on this config or something widened it since. Always empty on Windows, where modes carry no access control.
  • tasks.unreconciled[] is the §12.4 contradiction: a task holding a step run still marked running while sitting in a state that cannot be executing one — queued, done, aborted or archived. Each entry carries task_id, state and open_step_runs. Such a task is refused by admission and will not run until crash recovery reconciles it, so it also raises a tasks problem. The waiting states are deliberately absent: an open run is correct under awaiting_input and awaiting_gate.
  • Agent availability is re-probed by default, unlike GET /v1/agents. Authentication is not a function of the binary, so a cached logged_in: false would survive the user logging in — which would break the endpoint in the loop it exists for. Pass ?probe=false to be served from the same cache /v1/agents uses instead: it is for a caller that is not in that loop and wants the rest of the report cheaply — the TUI’s daemon panel, which opens on a keypress, is the one in the tree. vincent doctor always forces.
  • The database group measures growth and changes nothing about it. total_bytes is the file plus its WAL and SHM sidecars, which is the honest figure: the store runs in WAL mode, so size_bytes alone understates the footprint between checkpoints, and a missing sidecar counts as zero. table_rows is enumerated from the schema itself rather than from a fixed list, so its key set describes the database this binary is talking to and a later migration’s table appears with no client change. oldest_event_at is null on an install that has not recorded an event yet. workflow_snapshot_bytes totals the per-task workflow YAML — the second growth driver beside events, reported separately because one byte total cannot tell “many small events” from “a few enormous snapshots”. Nothing here prunes, warns, or moves the exit code.
  • known: false on database or tasks means the report was composed without a daemon (the CLI’s degraded path); over this endpoint they are always true.
  • An orphan is an entry under a data root that no task row claims — the same set GET /v1/maintenance/orphans returns, from the same scan. Each carries kind (worktree or transcript), task_id, size_bytes, and skip when gc would leave it alone (worktree_dirty, dirty_unknown, not_a_directory). orphans_known is false only when the daemon has no reclaimer wired.

POST /v1/doctor/fix takes { "force": true } or ?force and answers with what it did plus a report taken afterwards:

{ "actions": [
    { "action": "remove_worktree", "target": "…/worktrees/41",
      "status": "done", "freed_bytes": 2113536,
      "detail": "run `git worktree prune` in the project repo to clear its stale registration" },
    { "action": "compact_database", "target": "…/vincent.db",
      "status": "skipped", "detail": "2 task(s) in flight; a VACUUM would stall them mid-step" } ],
  "report": { "…": "…" } }

status is done, skipped or failed, and a skip always carries its reason. It is a separate method from the GET on purpose: a call that deletes directories is a different promise from a report.

orphans on GET /v1/info counts directories under {data_dir}/worktrees and {data_dir}/transcripts that no task row claims. It is computed per request from a readdir and one id query — no size walk, no git — so it is cheap and drops the moment gc runs. It is deliberately not on /v1/health, which stays { status, version } and is the one unauthenticated endpoint.

database on GET /v1/info carries the byte figures and only those:

{ "database": { "path": "…/vincent.db", "size_bytes": 262144,
                "wal_bytes": 4136960, "shm_bytes": 32768,
                "total_bytes": 4431872 } }

Three os.Stat calls per request, which is the same cheapness rule that admits orphans here. The row counts, the retention span and the workflow-snapshot total are scans and are on GET /v1/doctor instead — this endpoint is polled by the board, the projects view and the daemon view on every debounced refresh, and a COUNT(*) over a multi-million-row events table does not belong on that path. Nothing is cached, so nothing is stale. Like orphans, none of it goes on /v1/health.

The two maintenance endpoints share one body, so a dry run and a real run are compared field by field:

{ "orphans": [ { "path": "/home/u/.local/share/vincent/worktrees/41",
                 "kind": "worktree", "task_id": 41, "bytes": 13010000,
                 "skip_reason": "dirty_unknown", "removed": false } ],
  "mismatches": [ { "task_id": 58, "path": "…/worktrees/58", "state": "blocked" } ],
  "bytes": 13010000, "reclaimed": 0, "reclaimed_bytes": 0,
  "dry_run": false, "force": false }

kind is worktree or transcript. task_id is null when the directory’s name is not an id — the claim decides, not the name. skip_reason is why gc declined (worktree_dirty, dirty_unknown, not_a_directory); error is a removal that was attempted and failed, and the run continues past it, so reclaimed and reclaimed_bytes count only what actually went. mismatches[] is the reverse case — rows whose worktree_path points at a directory that is gone — reported only; gc modifies no row and deletes nothing outside the two data roots.

See vincent gc for the command over these endpoints.

Projects

Method Path Body / notes
GET /v1/projects List
POST /v1/projects { path, name?, default_branch?, default_workflow?, max_parallel_tasks? }
GET /v1/projects/{id}  
PATCH /v1/projects/{id} Any mutable field, including re-pointing path
DELETE /v1/projects/{id} Hard-deletes the project and its task rows
GET /v1/projects/{id}/github Can this project’s GitHub issues be read?
GET /v1/projects/{id}/github/issues Its issues, newest first — ?state=, ?limit=, ?workflow=

DELETE succeeds only when no non-archived tasks remain. ?force archives them first (force-removing worktrees), and is refused while any task is running. Before the rows go, every branch that has no commits past its base is deleted — archived rows included, because the cascade erases the branch names for good. A branch carrying a commit is never deleted, and no remote is ever touched here; failures are logged and the delete proceeds. Set delete_empty_branch_on_archive: false to disable the sweep.

GitHub issues

GET /v1/projects/{id}/github is the capability probe. It is a separate call rather than three fields on the project object because the board lists projects constantly, and answering it there would run gh auth status per project on every refresh:

{ "enabled": true, "repo": "lezli01/vincent", "available": true, "via": "gh" }

enabled is github.enabled. repo is derived from the project’s origin remote at the moment you ask, and is absent when that remote is not a github.com URL. via is gh or token. When available is false the body carries a reason and a human-readable message:

reason Meaning
disabled github.enabled is false
not_github No origin, or one that is not a github.com repository
no_credential gh is absent or logged out, and neither GITHUB_TOKEN nor GH_TOKEN is set
unauthorized GitHub rejected the credential
forbidden Authenticated, but not permitted to read this repository’s issues
not_found No such repository or issue
rate_limited The API rate limit is spent
timeout GitHub did not answer in time
unreachable The call failed, or the API answered something with no more specific meaning
bad_response The answer arrived and did not parse

Those reasons are the whole client-facing vocabulary. gh’s stderr and the API’s response body never appear in any of these fields — they go to the daemon log.

GET /v1/projects/{id}/github/issues lists the repository’s issues, newest first. Pull requests are never included. ?state= takes open (the default), closed or all; ?limit= caps the rows. There is no ?q= — narrow the list client-side, the way the TUI’s picker does.

[
  {
    "repo": "lezli01/vincent", "number": 200,
    "title": "GitHub integration: select a GitHub issue when creating a task",
    "body": "### Problem\n\n…", "url": "https://github.com/lezli01/vincent/issues/200",
    "state": "open", "labels": ["enhancement"], "author": "lezli01",
    "created_at": "2026-08-26T19:21:29Z", "updated_at": "2026-08-26T19:30:00Z",
    "fetched_at": "2026-08-26T20:04:11Z"
  }
]

Adding ?workflow=<name> attaches a prefill object to every row — the daemon’s own answer to “what would creating a task from this issue fill in”:

{ "prefill": { "title": "…", "description": "…\n\nGitHub issue #200: https://…",
               "fields": { "labels": "enhancement" } } }

POST /v1/tasks computes exactly the same prefill from the same code, so a preview a human accepted and a create call that names only the issue produce the same task. An unknown workflow name is 400 validation_failed.

Either endpoint answers 409 when the integration is not usable, carrying the reason a client can branch on:

{ "error": { "code": "invalid_state",
             "message": "GitHub is not available for this project: …",
             "details": { "reason": "no_credential" } } }

Workflows

Method Path Notes
GET /v1/workflows?project_id= The merged registry: built-in + global + that project’s, with shadowing applied
GET /v1/workflows/definition?name=&project_id= One workflow’s whole recursive structure, with the same shadowing applied
POST /v1/workflows/validate { yaml }{ valid, errors[], warnings[] }
POST /v1/resolve { workflow, project_id?, agent?, model?, effort?, title?, fields?, base_branch?, branch_name? } → resolution per step, plus the previewed branch name

Registry entries carry { name, scope, project_id, file, description, fields[], steps[], platforms[]?, platform_supported, requires_input, includes[]?, errors[]?, warnings[]?, error? }.

fields[] is the selected workflow’s ordered fields: declaration. Each entry is { name, label?, description?, type, required, pattern? }; type is always explicit (string when the YAML omitted it). An empty list means the workflow publishes no task-input contract, not that task fields are forbidden.

platforms[] is the entry’s platform restriction as the file declares it, and platform_supported is the daemon’s own verdict on it — the daemon is the process that would run the steps, so clients report that flag rather than comparing the list to their own OS. An entry with platform_supported: false is listed like any other, but POST /v1/tasks rejects a task naming it with a 400.

requires_input marks an entry with a step declaring on_input: require that leaves its agent to the task — the agent chosen for a task on it must be one that can stop and ask mid-run, or POST /v1/tasks rejects it with a 400 naming the step. Each adapter’s input_verdict in GET /v1/agents (supported, unsupported, unknown) is the verdict that gate uses; only unsupported refuses anything, so an agent that is not installed never blocks a task.

includes[] names the workflows this one splices in with type: include. Whether those names resolve is not answered here: which file a name reaches depends on the project’s registry, so an unresolvable or cyclic include is a 400 from POST /v1/tasks rather than an error on the entry.

One workflow’s full definition

GET /v1/workflows/definition?name=&project_id= returns the registry entry above — the same derived fields — plus definition, the workflow’s whole recursive structure. The list endpoint’s steps[] carries only { id, name, type, agent }, which is right for a registry listing and not enough to draw a graph: nested steps, fan-out lanes, merge, guards and loop drivers are gone before a client sees them.

GET /v1/workflows/definition?name=feature-pr&project_id=3
{
  "name": "feature-pr",
  "scope": "project",
  "project_id": 3,
  "file": "/src/app/.vincent/workflows/feature-pr.yaml",
  "platform_supported": true,
  "requires_input": false,
  "definition": {
    "name": "feature-pr",
    "fields": [
      { "name": "ticket", "label": "Ticket", "type": "string",
        "required": true, "pattern": "^OPS-[0-9]+$" }
    ],
    "defaults": { "agent": "claude", "model": "sonnet" },
    "steps": [
      { "id": "plan", "type": "agent", "prompt": "…", "check": "go build ./..." },
      { "id": "spread", "type": "fan_out",
        "lanes": [ { "id": "api", "steps": [  ] },
                   { "id": "web", "workflow": "web-feature", "if": "…" } ],
        "merge": { "on_conflict": "agent", "agent": { "id": "fixup",  } } }
    ]
  }
}

Three things about this contract are deliberate.

The name is a query parameter, not a path segment. A registry name is neither URL-safe nor unique. An entry whose file fails to parse is still listed, under a name taken from an unvalidated name: field or the filename — it may contain anything. And the loser of a duplicate name is listed beside the winner. The endpoint serves the shadowing winner and reports the scope and file it came from, so you can tell which entry you got.

A workflow that does not parse is a 200, carrying its errors[] and definition: null — the same rule the list follows in showing a broken file rather than hiding it. A 404 means no entry of that name in that project’s view of the registry at all.

Steps are reported as authored. Workflow defaults stay in their own block and are never folded into the steps that inherit them, so "agent": "claude" written on a step and the same value inherited stay distinguishable — the distinction the resolution order rests on, and the one anything that round-trips a workflow needs. For the resolved answer, use POST /v1/resolve.

POST /v1/resolve applies the resolution order to every step under a candidate task-level override, returning { value, source } per field — source being the winning level (step, task, workflow, adapter). Non-agent steps keep their index with null fields, so a client can zip the two lists positionally.

When the request names a project_id it also returns branch, the name this draft task would get:

{ "value": "feat/OPS-123-retry-logic", "source": "project", "placeholder": false }

source is the winning level of the branch chain (default, config, project, task). placeholder: true means value carries a literal <id> where the task id will go, because the id does not exist until the task is created — the daemon does not guess the next one. This is why a client should not render branch templates itself: resolution stays server-side so there is only ever one implementation of the precedence.

An empty value with source adapter means the adapter names no default of its own and the CLI decides at run time — which the TUI renders as “CLI default” rather than inventing a model name.

Resolution is server-side only. Clients report it; they never re-derive it.

Tasks

Method Path Notes
GET /v1/tasks?project_id=&state=&archived=&limit=&offset=&parent_id=&include_children= List. Fan-out lanes are excluded by default — parent_id lists one parent’s lanes in merge order, include_children=true the flat everything
POST /v1/tasks { project_id, workflow, title, description?, fields?, base_branch?, branch_name?, priority?, agent?, model?, effort?, github_issue? }branch_name is used verbatim and wins over any template
GET /v1/tasks/{id} Full task
PATCH /v1/tasks/{id} { priority } — queued/paused only
GET /v1/tasks/{id}/steps Every step run, every attempt, in position order. state may be stopped (a condition step ended the run, or a break ended its loop), and a skipped row carries skip_reason: "condition" when a guard skipped it and null when you did. A row inside a loop (§7.8) carries iteration (1-based; 0 outside one) and, for for_each, loop_item — a loop’s body steps share the loop’s step_index, so those are what tell two of them apart
POST /v1/tasks/{id}/steps/{step_id}/status { message }{ message } as stored. What the running step is doing, in its own words. Called by that step’s own process — see Step status

On POST /v1/tasks, the daemon validates the selected root workflow’s declared fields before inserting the task. Missing required values and invalid types or patterns return 400 validation_failed. The fields object remains open: additional names that the workflow did not declare are accepted, stored, and returned with the task.

github_issue is an issue number. The daemon fetches that issue, computes the prefill, and fills in whatever this request left unset — anything you send explicitly wins. For fields and description that is decided by presence: a key sent with an empty value is a row somebody cleared on purpose and stays cleared, so "description": "" creates a task with no description rather than the issue body. Only title keys on emptiness as well as absence — there is no such thing as deliberately creating an untitled task — so title becomes optional when github_issue is given.

The issue is stored on the task and served back on every task representation as github_issue, in the same shape the listing returns. It is a snapshot: it is never re-read, so editing the issue on GitHub afterwards does not change what a later step renders through .Issue. A request that names no github_issue makes no GitHub call at all. An unusable integration is the same 409 with details.reason the GitHub endpoints return.

Human actions, all POST /v1/tasks/{id}/…:

Path Valid from Body
/cancel most states  
/pause queued, running  
/resume paused  
/retry blocked { prompt_override?, run_override?, branch_override? }branch_override renames the branch before re-admission, which is how a branch_exists block is recovered
/repair blocked { prompt, agent?, model?, effort? } — runs one ad-hoc agent in the task’s existing worktree, then returns the task to blocked at the same step with the same reason
/skip blocked, awaiting_gate  
/approve awaiting_gate  
/reject awaiting_gate  
/answer awaiting_input { answers?, allow? }
/archive done, aborted { force? } or ?force
/follow_up done, aborted { prompt? \| run? \| workflow?, agent?, model?, effort? } — exactly one of the three; runs it in the task’s existing worktree, then returns the task to the state it came from

Anything else returns 409 with details.state. See Task lifecycle.

/repair runs one throwaway agent against a blocked task’s worktree — the escape hatch for a block that retry cannot clear because the worktree itself is wrong. prompt is required and is literal text, not a template: it is prose, and the daemon assembles the failure context around it (the task, the blocked step’s rendered prompt or command, the reason and exit codes, the last 200 lines of the failed attempt’s transcript and the path to the rest). An empty or whitespace-only prompt is 400 validation_failed.

The optional agent / model / effort apply to that one run and take precedence over the task’s overrides and the workflow’s defaults:; they are validated exactly as POST /v1/tasks validates a task’s, so an unregistered agent or a known-invalid model is a 400 and a value no catalog recognizes comes back in warnings:

{ "id": 7, "state": "queued", "…": "…", "warnings": [] }

The repair decides nothing about the blocked step. Whatever the agent exits with, the task goes back to blocked at the same step with the same block_reason, and you retry, repair again, skip or cancel. Its attempt is recorded as an ordinary step run under the reserved step id __repair at the blocked step’s index, so GET /v1/tasks/{id}/steps returns it with its own transcript, tokens and cost — and the blocked step’s retry budget is untouched by it.

/follow_up runs one more piece of work in a finished task’s worktree and branch, before you archive it. Exactly one of three fields says what to run:

Field Runs
prompt an agent, with this as its instructions
run a shell command, under the daemon’s shell (/bin/sh, or pwsh on Windows)
workflow a workflow from the registry, against this task’s worktree instead of a new one

Naming none of them, or more than one, is 400 validation_failed. prompt and run are literal text, not templates — the daemon escapes them when it compiles the one-step workflow it runs, so a {{ you type is two characters. If you want templating, put it in a workflow and name that.

A workflow name is resolved through the registry now, not at admission: an unknown name, a workflow that cannot run on this host, one that fails validation once its includes are expanded, or a fan-out tree past fan_out.max_depth from this task’s own depth are all 400s. What validates is stored on the task and is what runs, so editing the file afterwards does not change the run in flight.

The optional agent / model / effort behave exactly as /repair’s do, except that an explicit agent field on a step of a named workflow still wins — that is what a step field means. The response is the task, now queued, plus warnings.

The run returns the task to the state it came from: done to done, aborted to aborted, whatever it exits with. A follow-up never changes a task’s verdict. It is repeatable, and each run is a round: round n of a task whose workflow has k steps records its rows at step_index = k + n - 1, so GET /v1/tasks/{id}/steps returns them past the workflow’s last index and step_total does not change. A row with step_index >= step_total is a follow-up row; render it as its own round rather than as a step of the workflow.

A follow-up step that fails blocks the task at that index. /retry there re-runs the follow-up where it stopped, /repair runs an ad-hoc agent against that failure, /skip abandons the follow-up and restores the task’s original state, and /cancel aborts — which means done → aborted is reachable while a follow-up is running. /retry with prompt_override or run_override is 400: an override rewrites a step in the task’s snapshot, and a follow-up is not in it.

/archive is the one action whose response is not just the task. When it looks at the branch, it adds a branch object beside the task fields:

{
  "id": 7, "state": "archived", "…": "…",
  "branch": {
    "name": "vincent/7-file-an-issue",
    "result": "deleted",
    "remote": { "remote": "origin", "ref": "refs/heads/vincent/7-file-an-issue", "result": "deleted" }
  }
}

result is deleted (no commits past its base), has_commits (kept), unknown (git could not judge it — base branch renamed away, repository gone) or error (the delete itself failed), with git’s message in error for the last two. The remote object appears only when delete_remote_branch_on_archive is on and the local delete succeeded; its result is deleted, no_upstream or error. The whole branch object is absent when nothing was checked — the cleanup is off, or the task never had a branch of its own.

None of it affects the status code: a branch problem never fails an archive.

Four details worth knowing:

  • A queued task may be waiting on a clock, not a slot. Every task representation carries queued_reason and admit_not_before (RFC3339, or null). Both are null for an ordinarily queued task. Two reasons set them, and vincent tries again at that timestamp unattended in both cases: usage_limit, the agent’s usage window being spent, and retry_backoff, a step’s failed attempt being paced by its retry_backoff. They are separate from block_reason, which still means only “stopped, needs a human” — the task is not blocked. Treat the set as open: a client should render whatever string it is given rather than switching on the two it knows.

  • List rows carry the board fieldsproject_name, step_total, step_name, status_message, and cost_usd / input_tokens / output_tokens rolled up across every attempt — so a board renders without an N+1. Those are list-only; GET /v1/tasks/{id} serves the same numbers per attempt in steps[].

Every task shape carries parent_task_id, lane_id and lane_order, all null for a root task. GET /v1/tasks/{id} additionally carries children whenever the task has lanes:

"children": {
  "total": 4, "settled": 2,
  "by_state": {"done": 2, "blocked": 1, "running": 1},
  "blocked": [17], "awaiting_gate": []
}

It covers the whole subtree, not just direct lanes, and is computed per request from one recursive CTE rather than stored — a counter would be a second truth that drifts from the rows it counts. blocked and awaiting_gate are ids: fetch the ones you decide to show. This is what pays for hiding lanes from the list, since a blocked lane would otherwise be invisible.

Both the list and the detail endpoint carry loop while a task’s current step is a loop (§7.8), and omit it otherwise:

"loop": { "driver": "for_each", "iteration": 4, "max_iterations": 10, "item": "internal/store" }

iteration is the pass in progress (0 before the first one starts) and max_iterations is the largest it could reach — the count: itself, or the ceiling a for_each is bounded by, whose real length is only known at run time. It is on the list endpoint too, so a board can render loop 4/10 without a request per row. Like children, it is derived per request from the step rows rather than stored: a persisted loop cursor would be a second truth that recovery would have to reconcile. There is deliberately no step-lifecycle event for iterations — ten passes of a four-step body would put forty durable events on the stream to say what forty rows already say.

  • ?archived= defaults to false. true selects only archived tasks, all returns both.
  • Every task representation carries available_actions (the actions valid right now) and pause_requested, so clients never restate the state machine. The detail response adds workflow_steps[] — this task’s snapshot, which is what edit-and-retry prefills an editor with, reflecting any earlier edit. A step spliced in by type: include carries resolved_from[], the chain of workflows it came through, outermost first.

Task creation validates the agent/model/effort override: a known-invalid value is 400, a catalog-unknown one is reported in warnings[] on the 201 body. It is also where a workflow’s includes are resolved into the snapshot, so an include that cycles, names a workflow this project cannot see, nests past include.max_depth, brings a step id already in use, or is restricted to another platform is a 400 here.

Step status

A running step can say what it is doing, in its own words:

POST /v1/tasks/{id}/steps/{step_id}/status
{ "message": "3 tests red in internal/store" }

The answer is { "message": … } — the value as stored, so you can see what a reader will see.

The caller is the step’s own process. It addresses itself with two of the VINCENT_* variables the daemon sets on every agent and command step, VINCENT_TASK_ID and VINCENT_STEP_ID, and the usual way to call it is not curl but vincent status, which reads both and needs no arguments beyond the message.

The path names a step id, not a step_runs row id, because a step knows which step it is and cannot know its row. It names a step rather than only the task because a parallel group’s sub-steps share one task and run at the same time; within one task a step id has at most one running row.

What the daemon does with the message:

  • Bounds it rather than validating it. It is flattened to a single line, stripped of control characters and truncated to 256 bytes. Over-long text is never a 400 — a step reporting progress should not fail because it was wordy. An empty message clears the status.
  • Refuses a step that is not running, with 409 invalid_state. An unknown task is 404. A write is never silently dropped, so a script still narrating after its step was killed finds out.
  • Paces writes without rejecting them. Two writes for one step run inside one second coalesce to the later value, which lands when the second is up. The first write after a quiet period is always immediate.
  • Announces the change as the durable task.status_changed event — but only when the stored value actually changed.

Where it shows up: status_message on every step-run object from GET /v1/tasks/{id} and GET /v1/tasks/{id}/steps, and on each row of GET /v1/tasks, denormalized from the task’s newest step run so a board never fetches step rows for it. It is null when the step said nothing, which is the ordinary case — only agent and command steps run a process, and one only speaks if its prompt or script was written to.

It is not a failure reason. failure_reason is a closed set of daemon-authored constants and is vincent’s own verdict; status_message is free text the step chose, possibly long before it died. Render it as the step’s last status, not as the cause of anything.

Transcripts and diffs

GET /v1/tasks/{id}/steps/{run_id}/transcript?offset=&tail=&format=
GET /v1/tasks/{id}/diff

The transcript is the attempt’s JSONL file, ranged:

  • offset= (bytes) and tail= (last N bytes) are mutually exclusive.
  • tail opens at the start of the record its byte count lands in, so a window narrower than the last record still returns that record rather than nothing. offset is taken as given.
  • The body always ends on a complete line, and X-Next-Offset reports that boundary — never mid-record, so a follow-up fetch on a file still being appended to resumes cleanly.
  • format=normalized maps every line through the owning adapter’s parser into the live-output shapes plus agent.result, agent.error, the vincent.* kinds, and agent.raw for anything unrecognized. That is one render path for live tail and scrollback alike. Absent, you get the raw file byte for byte.

Because normalization runs on read, enriching a parser improves transcripts already on disk.

GET …/diff is a unified diff of the worktree against the merge-base with the base branch, including uncommitted changes. Untracked files are excluded — a documented limitation.

Events (SSE)

GET /v1/events?types=&project_id=
GET /v1/tasks/{id}/events

Two kinds of stream, with deliberately different guarantees.

State events — durable

Persisted to the events table with a monotonic id and emitted with id: set, so a client reconnecting with Last-Event-ID misses nothing.

task.created            task.state_changed      task.priority_changed
task.step_advanced      task.status_changed     task.children_changed
project.*               workflow.registry_changed
agent.quota_changed     daemon.shutting_down

Payloads carry ids and the new state, not full objects — clients re-fetch what they need.

  • A connection without Last-Event-ID starts live at the next committed event. The stream never replays history unasked: catch-up is a REST snapshot first, then the stream.
  • There is no separate task.archived or task.awaiting_input type. Both are task.state_changed with the appropriate to; the awaiting_input payload additionally carries the request kind and a one-line summary, which is the alert clients key off. The full request comes from GET /v1/tasks/{id}.
  • task.step_advanced carries { current_step } when the engine moves the cursor without a state change, so a board’s k/n tracks a run instead of freezing.
  • task.children_changed carries { task_id, child_id, to_state } and is emitted on every fan-out ancestor when a descendant is created or transitions — re-fetch the children rollup when you see one. It exists because the per-task stream filters on task_id alone, so a root’s stream would otherwise never see a depth-2 transition.
  • task.status_changed carries { task_id, step_id, message } when a running step changes what it says about itself — see Step status. It is on the durable side deliberately, so a client that blinks recovers the message through Last-Event-ID. It is emitted only when the stored value actually changed, so a step re-asserting the same line does not wake you.
  • agent.quota_changed carries { agent, spent, resets_at, source } and no task_id: the fact is about an adapter, not about any one task. It is emitted when a usage_limit stop is observed and when a successful run retires an observation — never on a re-observation identical to what is already stored, and never merely because a window lapsed. Re-fetch quota from /v1/agents or /v1/info when you see one.

Live output — ephemeral

agent.output, agent.tool_use, agent.tool_result, agent.thinking, agent.usage and command.output chunks stream on the per-task stream only and are not written to the events table. Their durable copy is the transcript file.

Each chunk is one SSE event, flushed on a ~100 ms coalescing timer, and carries:

  • run_id — the step-run row that produced it, and
  • offset — the byte position in that attempt’s transcript file after its line was written.

Together those make the catch-up seam exact: fetch the transcript, then discard buffered chunks whose run_id matches the attempt you fetched and whose offset is at or before the fetch’s X-Next-Offset. run_id is load-bearing on its own, because offsets restart at zero in every attempt’s file.

Last-Event-ID on the per-task stream resumes its durable events only; live output is not replayable.

Back-pressure

The two kinds fail differently on purpose. A slow subscriber has live output chunks dropped — the transcript is the durable copy. A slow subscriber to durable state events is disconnected instead, so it reconnects and resumes from the events table via Last-Event-ID. Fan-out is post-commit only: the store publishes after the database has recorded the event.


See also