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
- Errors
- Request bodies
- Daemon
- Doctor
- Projects
- Workflows
- Triggers
- Tasks
- Chats
- Transcripts and diffs
- Events (SSE)
- MCP
Transport and auth
- HTTP/1.1 + JSON on
127.0.0.1only. No TLS — it is loopback. - Every request needs
Authorization: Bearer {token}, where the token is the contents of{data_dir}/token(created0600). This is what keeps other local users and drive-by browser requests out; CORS is additionally disabled. - Discovery: read
{data_dir}/daemon.jsonfor the port, thenGET /v1/health. - Versioning: path-prefixed (
/v1), additive changes only within a version. Idempotency-Key(optional,POST /v1/tasksonly) makes a create replayable: if the daemon committed the task but you never saw the response, re-sending the same body under the same key returns that task instead of making a second one. Up to 255 bytes of printable ASCII; anything else is400 validation_failed. Keys are scoped to the route and kept for 24 hours, which is fixed rather than configurable. Every other mutating route is already replay-safe and ignores the header — seePOST /v1/tasksfor what a replay returns.
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.
Almost every 409 carries the code invalid_state; what varies is details. A
conflict that is not about a task’s state names itself in details.reason —
idempotency_key_reused when an Idempotency-Key is re-sent with a different
body, the GitHub integration’s reasons on the issue routes, and why a trigger
is not armed on its ingress. Branch on details, not on a
per-case code.
The exceptions are the chat conflicts, which have codes of their own because a client does something different for each:
| Code | Means | details |
|---|---|---|
chat_cap_reached |
A chat turn was refused because max_parallel_chats chats already hold a live process (Sending a turn) |
state |
repo_operation_in_progress |
A chat hand-off was refused because its worktree is partway through a git operation | operation |
task_locked_by_chat |
A task action was refused because a chat linked to the task is open. Close that chat, or cancel the task |
chat_id |
task_has_no_worktree |
A chat cannot be opened on this task: it never got a worktree, and vincent does not create one for a chat. Also a chat on a task that has lost its worktree, asked for its skills | state, action; task_id from the skills route |
chat_linked_to_task |
Archive, hand-off, or a delete with delete_branch=true on a chat linked to a task. The worktree and branch are that task’s |
task_id, state, action |
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, PATCH /v1/workflows, POST /v1/triggers/validate, PATCH /v1/triggers/{id}, POST /v1/triggers/{id}/test, POST /v1/triggers/{id}/events — the bodies that carry a prompt, a workflow or trigger source, or a vendor’s event payload |
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 |
ops entries in one PATCH /v1/workflows or PATCH /v1/triggers/{id} |
512 |
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, slots, orphans, the database’s byte footprint, and the container runtime |
GET |
/v1/config |
The effective global config — every key in config.yaml, including the tui section the daemon only relays |
PATCH |
/v1/config |
Partial, snake_case, mirroring the read shape. Validates, writes config.yaml comment-preservingly, and applies the result before answering → the config in force. An invalid patch is 400 validation_failed with the file byte-identical |
GET |
/v1/agents |
Per-adapter availability plus model/effort options. ?refresh=true forces a re-probe — including the reported usage quota |
POST |
/v1/agents/{name}/quota |
Pushes a usage reading for one adapter → 204. What vincent statusline posts from Claude Code’s status line. Not an MCP tool |
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 |
GET |
/v1/update |
The daemon’s cached release check — see Update check. Never polls |
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": true,
"supports_resume": true,
"supports_skill_listing": true, "skill_sigil": "/", "skill_position": "leading",
"supports_file_mentions": true, "file_mention_sigil": "@",
"file_mention_position": "anywhere", "file_mention_expands": true,
"version_verdict": "tested", "tested_versions": "2.1.224, 2.1.226, 2.1.268, 2.1.277",
"restricted_verdict": "supported",
"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 a definite boolean from each adapter’s own
authentication probe — claude via auth status, 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, cannot be spawned or answers something unreadable reports
null, not false. claude is asked only on builds from 2.1.41 (where
auth status was introduced) up to, not including, 3.0.0; any other claude
reports null. true means the CLI found credentials configured — a login, an
API key, a cloud-provider switch — not that they were checked, for every
adapter.
Adapter health is five separate facets, and each has exactly one field:
| Facet | Field | Notes |
|---|---|---|
| installed | available, path |
|
| authenticated | logged_in |
null = the probe could not tell; never a guess |
| protocol-compatible | supports_input, version_verdict |
|
| permission-compatible | restricted_verdict |
|
| model-catalog | probe_error |
non-null = you are reading the curated catalog |
version_verdict is tested, untested, incompatible, or "" when nothing
is installed to judge, and tested_versions is the build list it was compared
against — whole-string equality, since cursor’s calver-plus-sha version admits
no range. It is advisory: no endpoint refuses anything on account of it, and
untested is the normal answer for a current CLI.
restricted_verdict is supported, unsupported or unknown, and it is the
one verdict here that refuses something: POST /v1/tasks answers 400
validation_failed when a step running permission_mode: restricted resolves to
an adapter reported unsupported (cursor on Windows). It needs no installed
binary — it is a fact about the adapter and the operating system — so it is
answered even for a CLI that is missing.
version_verdict, tested_versions and restricted_verdict also ride
GET /v1/info’s and GET /v1/doctor’s agents[], beside supports_input.
supports_resume is whether the adapter can resume its own session, and so
whether it may hold a chat: true for all three shipped
adapters. It is the same answer POST /v1/chats gates on, so a
picker built on it and the agent_cannot_resume refusal cannot disagree, and
like restricted_verdict it is answered for a CLI that is missing. It is
null — never false — when the daemon has no adapter registry to ask, and a
daemon that predates the field omits it: both mean “no judgement”, and nothing
may be filtered out on either. It rides this route only.
supports_skill_listing, skill_sigil and skill_position describe the
adapter’s skills. supports_skill_listing is whether the adapter can list the
skills its CLI would load at all — true for claude and codex, false for
cursor today. It is a fact about the adapter, not about the installed build: true
does not promise that the build you have will list, and a build that cannot is
found out only when a chat’s skills are read. skill_sigil (/ or $) and
skill_position (leading: only at the start of the message; anywhere) are
how a chat message names a skill — claude / leading, codex $ anywhere,
cursor / anywhere — and both are "" for an adapter that cannot invoke
skills. All three are null when the daemon has no adapter registry to ask, as
supports_resume is, and ride this route only. The per-directory skill list is
not here: this answer is cached by binary identity, and a skill list also
depends on the directory the agent runs in.
supports_file_mentions, file_mention_sigil, file_mention_position and
file_mention_expands describe how a message names a workspace file.
supports_file_mentions is whether the adapter can do it at all — true for
all three shipped adapters, so it tells them apart from nothing; it is here so
a client can tell “cannot mention” from “nobody can say”. The one that
differs is file_mention_expands: true means the CLI itself puts the
mentioned file in front of the model, false means the path arrives as prose
and the model reads it with a tool — a hint rather than guaranteed context.
claude expands, codex and cursor do not, which is what vincent agents notes
as no @ file expansion. file_mention_sigil (@) and
file_mention_position (leading: only at the start of the message;
anywhere) are the syntax, and both are "" for an adapter that cannot
mention. All four are null when the daemon has no adapter registry to ask,
as supports_resume is. They are the answer for a client that has no chat
yet — vincent agents, and any agent picker; a client that already has a
chat reads the same facts from that chat, so it needs only one call.
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" }
pathmust 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}/transcriptsis refused too — the archive would read itself. Every one of these is a400 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 restoreruns client-side, because the daemon it would overwrite has to be down. - The daemon can also take this archive on a schedule, set by
backupinconfig.yaml. There is no endpoint for it:PATCH /v1/configturns it on, and the timer’s state is thebackupgroup ofGET /v1/doctor.
Usage quota
quota is one adapter’s usage window, and it is one of two different facts.
A reported reading is a measurement of a window still open, taken from the
adapter’s own surface. An observation is what the daemon watched happen —
the usage_limit stops it has seen for itself. source says which you are
holding, a reading wins where there is one, and null — never a zeroed block —
means neither exists, which is the normal state.
"quota": {
"spent": false,
"used_percent": 53,
"window": "7d",
"observed_at": "2026-09-02T09:14:00Z",
"resets_at": "2026-09-06T11:00:00Z",
"resets_at_reported": true,
"source": "codex_app_server",
"windows": [
{ "name": "primary", "used_percent": 28, "window": "5h",
"resets_at": "2026-09-02T13:00:00Z", "resets_at_reported": true },
{ "name": "secondary", "used_percent": 53, "window": "7d",
"resets_at": "2026-09-06T11:00:00Z", "resets_at_reported": true }
]
}
sourceisobserved,codex_app_server(codex answersaccount/rateLimits/readovercodex app-server --stdio) orclaude_status_line(Claude Code pushes throughvincent statusline). cursor has no quota surface at all and reports nothing, which is unchanged.windows[]is every window the source named — always an array, nevernull, and empty for an observation. Names are the source’s own vocabulary (primary/secondary,five_hour/seven_day) and are not normalized across vendors, because two vendors’ windows are not the same thing.used_percent,window,resets_atandresets_at_reportedcarry the tightest window of a reading: the highest percentage, ties broken by the earliest reset. They staynull/empty for an observation, which measures nothing. A client written beforewindows[]existed keeps working and gets the number that matters.spentis derived per request, and how depends on the source. An observation is spent whilenow < resets_at; a window that has reset does not delete it, sospent: falsewith the timestamps intact is how “this adapter ran out at 14:05 and has since recovered” is said. A reading is spent atused_percent >= 100— its reset is always in the future, so the observation’s rule would light a badge permanently.resets_at_reportedseparates a fact from an estimate.truemeans the source named the reset;falsemeansusage_limit_recheck_intervalsupplied it, or that a reported window named no reset at all — in which caseresets_atis the zero time and must not be rendered as a time.- A reading is held in memory, not in the database: it is exactly as
durable as the daemon, and a restart drops it until the next probe or push.
A reading is refreshed at most every five minutes on an ordinary request, and
unconditionally by
GET /v1/doctor. - An observation is retired by evidence: the next successful agent step on that adapter deletes it. That rule is about observations only — a step completing says nothing about a percentage a vendor reported. Nothing sweeps either on a timer.
- Nothing here can fail a probe. A missing CLI, an unauthenticated account, a
handshake that times out or a malformed answer all degrade to the observation
behaviour, and
probe_errorkeeps meaning only “the option probe failed”.
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.
POST /v1/agents/{name}/quota is how a reading that cannot be fetched arrives
instead:
{ "source": "claude_status_line",
"windows": [ { "name": "five_hour", "used_percent": 28, "window": "5h",
"resets_at": "2026-09-02T13:00:00Z" } ] }
It answers 204, 404 for an adapter this daemon does not have, and 400 for
a body naming no window. resets_at is optional — omit it rather than sending a
zero time. It is not an MCP tool: an agent must not be able to forge a
daemon-level fact about the host it runs on.
Update check
GET /v1/update serves the release check
update governs:
{
"enabled": true,
"current_version": "v0.4.1",
"latest_version": "v0.5.0",
"update_available": true,
"published_at": "2026-08-21T09:31:07Z",
"release_url": "https://github.com/lezli01/vincent/releases/tag/v0.5.0",
"checked_at": "2026-08-29T10:00:00Z"
}
It serves the cache and nothing else. There is deliberately no ?refresh:
update.check: false promises the daemon makes no outbound request, and a
parameter that made one on demand would hand any client the ability to break
that. vincent update --check queries the release feed
itself instead, which is also why it works before the first poll and with no
daemon running.
checked_atisnullandlatest_versionempty until a poll succeeds. That is the never-polled state and it is not the same answer as “no update available” — a daemon that started ten seconds ago does not know yet.update_availableis the daemon’s verdict, computed server-side, so every client agrees. Adevbuild is never reported as behind.current_versionis the daemon’s build. The binary that made the request may be a newer one, which is exactly whatvincent daemon statusreports after an update.errorcarries why the last poll failed and is absent when it worked, as above. A quietly failing check would otherwise look identical to one with nothing to report.- A prerelease never appears here.
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,
"supports_input": false, "version_verdict": "tested",
"tested_versions": "0.142.5, 0.147.0, 0.150.1, 0.154.0",
"restricted_verdict": "supported" } ],
"skills": [ { "name": "vincent-workflows",
"description": "Create, edit, review, and validate vincent workflow YAML…",
"shipped_version": "1.0.0", "installed_version": "1.0.0",
"state": "current",
"store_path": "/home/you/.agents/skills/vincent-workflows",
"links": [ { "agent": "claude", "adapter": "claude",
"path": "/home/you/.claude/skills/vincent-workflows",
"symlink": true } ] } ],
"backup": { "known": true, "enabled": true,
"dir": "/home/you/.local/share/vincent/backups",
"interval": "24h0m0s", "keep": 7,
"last_success_at": "2026-09-17T03:00:00Z",
"last_attempt_at": "2026-09-17T03:00:00Z",
"next_due_at": "2026-09-18T03:00:00Z",
"last_bytes": 1503238553, "retained": 7 },
"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 exit1(config that does not parse, an unresponsive daemon, a failedintegrity_check, a schema newer than the binary, orphaned worktrees, a failed scheduled backup, 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" }, whereremediationis the exactchmod. It never reachesproblems[]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 markedrunningwhile sitting in a state that cannot be executing one —queued,done,abortedorarchived. Each entry carriestask_id,stateandopen_step_runs. Such a task is refused by admission and will not run until crash recovery reconciles it, so it also raises atasksproblem. The waiting states are deliberately absent: an open run is correct underawaiting_inputandawaiting_gate.fan_outrows are excluded for the same reason in the other direction: a fan-out round’s row is opened by the park and finalized by the merge admission (§7.6), so the parent isqueuedbetween the two holding a row that is this round’s rather than an unfinalized previous attempt. Admission skips it too, so such a task is not refused.- Agent availability is re-probed by default, unlike
GET /v1/agents. Authentication is not a function of the binary, so a cachedlogged_in: falsewould survive the user logging in — which would break the endpoint in the loop it exists for. Pass?probe=falseto be served from the same cache/v1/agentsuses 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 doctoralways forces. skills[]is one row per published skill, never one per agent. TheskillsCLI keeps a single copy in a global store,~/.agents/skills/, and links it into each agent’s directory, so a per-agent version would be the same string on every row.installed_versionis absent for a copy that carries nometadata.version— every copy installed before that marker existed — and such a copy isolder, notunreadable.stateis one ofabsent,current,older,newer,differs(unequal and at least one side is not semver, so no direction is claimed) andunreadable.links[]is what is on disk and can name agents vincent does not drive. Nothing here ever reachesproblems[]— seevincent skills.- The database group measures growth and changes nothing about it.
total_bytesis the file plus its WAL and SHM sidecars, which is the honest figure: the store runs in WAL mode, sosize_bytesalone understates the footprint between checkpoints, and a missing sidecar counts as zero.table_rowsis 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_atisnullon an install that has not recorded an event yet.workflow_snapshot_bytestotals the per-task workflow YAML — the second growth driver besideevents, 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. backupis the scheduled-backup timer’s state (seebackup).enabled,dir(resolved, so""reads as{data_dir}/backups),intervalandkeepare the settings in effect.last_success_atcomes from the newest scheduled archive’s name, so it survives a restart.last_attempt_at,next_due_at,last_bytesandretained(how many scheduled archives are indir) describe the timer’s latest check. Timestamps arenullwhen there is nothing to report.last_errorandprune_errorare omitted when empty. Alast_errorwhileenabledistrueputs abackupentry inproblems[], with the messagethe last scheduled backup failed: <error>, until an attempt succeeds. Aprune_error, an old archive that could not be deleted after a successful run, never does, and neither does anext_due_atin the past. Reading this group never takes a backup.known: falseondatabase,tasksorbackupmeans the report was composed without a daemon (the CLI’s degraded path); over this endpoint they are alwaystrue. Abackupgroup withknown: falsestill carries its settings, read fromconfig.yaml, and never raises a problem.- An orphan is an entry under a data root that no task row claims — the same
set
GET /v1/maintenance/orphansreturns, from the same scan. Each carrieskind(worktreeortranscript),task_id,size_bytes, andskipwhen gc would leave it alone (worktree_dirty,dirty_unknown,not_a_directory).orphans_knownisfalseonly 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.
slots on GET /v1/info is how much of max_parallel_tasks is in use right
now, counted the way the scheduler counts it when it decides whether one more
task may start:
{ "max_parallel_tasks": 6,
"slots": { "used": 3, "lanes": 2, "awaiting_input": 1 } }
A task holds a slot while it is running or awaiting_input — a task
parked on a question still owns a live agent process, so it costs a slot like a
working one — and the count is over every task row, fan-out lanes included.
That is why it is served rather than derived: GET /v1/tasks omits descendants
by default, so a client walking its own list cannot see a lane at all, and a
client matching state == "running" misses the questions too. lanes and
awaiting_input are subsets of used, published so a client can explain a
number that does not match the rows it is showing — the TUI renders
3/6 running · 2 lanes · 1 on input from exactly these three fields. It is one
indexed COUNT over a human-sized table, and the same one the scheduler already
runs on every admission pass.
container on GET /v1/info reports the container runtime the way agents[]
reports the adapters — presence, never a verdict on an image:
{ "container": { "enabled": true, "image": "ghcr.io/acme/dev:latest",
"runtime": "docker", "available": true } }
enabled is whether container.image names an image at all; available is
whether that runtime binary answered, which is probed either way so a client can
say “this would work if you turned it on”. A failure adds error with the
runtime’s own words, and on a Windows daemon available is false with an
error saying containerized tasks are not supported there. Whether a particular
image exists is not here: that is a registry pull, and it happens when a task
is admitted.
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}/branches |
Its local git branches, each with the working tree holding it |
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= |
GET |
/v1/projects/{id}/github/pulls |
Its pull requests, 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.
Every project object carries slots_used: how many of that project’s tasks hold
a concurrency slot right now, by the same definition slots on GET /v1/info
uses — running or awaiting_input, fan-out lanes included. It is the
numerator that project’s max_parallel_tasks is applied against, so a client
renders slots_used / max_parallel_tasks without counting rows itself. A
project holding none reads 0, never absent, and a project created a moment ago
reads 0 because it owns no task yet.
GET /v1/projects/{id}/branches lists the project’s local branches, so a
client can offer them where a branch name is asked for instead of making a user
remember one:
{ "branches": [
{ "name": "main", "checked_out_in": "/home/me/code/app",
"main_checkout": true, "current": true },
{ "name": "feat/OPS-412", "checked_out_in": "/home/me/.local/share/vincent/worktrees/12" },
{ "name": "spike/parser" }
] }
checked_out_in is the working tree holding the branch and is absent when none
does; main_checkout says that tree is the project itself, computed server-side
so a client never compares two paths git and the project record may spell
differently; current marks the branch the project’s own checkout has at HEAD.
Remote-tracking refs are deliberately not listed — git worktree add cannot
adopt one. It is a listing, not a validator: free text is still accepted
wherever a branch is named, and a name not in this list is the ordinary
cut-a-new-branch mode. A project path that has gone missing is a
400 validation_failed.
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 or pull requests. The same 403 on a write is no_write_scope |
not_found |
No such repository, issue, pull request or check run. GitHub also answers 404 to some writes the credential may not make, and that is still not_found |
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 |
pull_exists |
A pull request for this branch and base already exists (pull request creation only) |
bad_request |
GitHub refused the values as unusable — a base branch that does not exist, say — or vincent refused them before sending, such as a re-run of a run that is not a failed Actions check on the head (pull request creation and re-run) |
no_write_scope |
The credential may read this repository but not write to it: a 403 on any write that is not a spent rate limit |
not_mergeable |
GitHub will not merge the pull request as it stands — closed or already merged, a draft, conflicted, or blocked by something other than a running check (merge only) |
checks_running |
The merge is blocked and a check on the head commit has not finished (merge only) |
branch_behind |
The branch is behind its base and the repository requires it to be up to date (merge only) |
head_changed |
The pull request’s head is not the commit the merge was confirmed for, so nothing was merged (merge only) |
Those reasons are the whole client-facing vocabulary; everything from
pull_exists down comes only from a write —
POST /v1/tasks/{id}/github/pull/create or one of the routes that
act on a linked pull request. 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": "#200 …", "description": "…\n\nGitHub issue #200: https://…",
"fields": { "issue": "200", "labels": "enhancement" } } }
title is the issue title prefixed #N. fields holds only the declared
fields named exactly issue, labels, assignee or milestone whose declared
type and pattern accept the value — issue being the issue number, which is how
a command step reads it, since step bodies see the environment rather than the
template context.
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.
GitHub pull requests
GET /v1/projects/{id}/github/pulls lists the repository’s pull requests,
newest first. ?state= is open (the default), closed or all; ?limit=
caps the rows; ?workflow= adds a computed prefill per row — the same shape
the issue listing carries, and the same one POST /v1/tasks applies when you
name a pull request. It goes through the same capability gate as the issue
listing, so a disabled integration or a project whose origin is not a
github.com repository makes no call at all.
The default stays open-only on purpose: it is the question the screen usually asks, and pulling a repository’s whole pull-request history to answer it would be paid for by everyone. Reaching a closed or merged one is a choice you make.
It is a pure read: it fetches, normalizes, sorts and returns, and persists nothing. Linking is the daemon’s own job (below).
[
{
"repo": "lezli01/vincent", "number": 412,
"title": "List a GitHub project's open pull requests",
"url": "https://github.com/lezli01/vincent/pull/412",
"state": "open", "draft": false, "merged": false,
"head_branch": "vincent/231-list-open-pull-requests", "base_branch": "master",
"author": "lezli01",
"created_at": "2026-08-26T19:21:29Z", "updated_at": "2026-08-27T09:02:11Z",
"fetched_at": "2026-08-29T12:00:00Z",
"task_id": 61, "link_source": "auto"
}
]
state is open or closed; a merged pull request is closed and carries
merged: true. task_id and link_source are present on the rows a task
claims — auto when the daemon matched the head branch, human when a person
said so.
Nothing about a pull request is ever stored. What a task keeps is a pointer —
github_pull, holding {repo, number, source, suppressed, linked_at, branch,
fork} — and everything renderable is re-read on every request, because draft,
state and merged status are live by nature and a snapshot of them would read
exactly like a current one while being wrong. branch and fork are not
renderable either: branch: true says this task’s branch_name is this pull
request’s head branch, because the task was created from it
(github_pull on POST /v1/tasks), and fork: true that the head
lives in another repository, so the branch carries no upstream and nothing can be
pushed back. Both are absent on a link a reconciler or a human made.
| Method | Path | Body / notes |
|---|---|---|
GET |
/v1/tasks/{id}/github/pull |
This task’s pull request, fetched live |
POST |
/v1/tasks/{id}/github/pull |
{ number } — link by hand |
DELETE |
/v1/tasks/{id}/github/pull |
Unlink, and remember the refusal |
GET |
/v1/tasks/{id}/github/pull/checks |
The live check rollup for the linked pull request’s head commit |
POST |
/v1/tasks/{id}/github/pull/create |
{ title, body, draft } — push the branch and open the pull request |
POST |
/v1/tasks/{id}/github/pull/merge |
{ method, head_sha } — merge the linked pull request, pinned to that head |
POST |
/v1/tasks/{id}/github/pull/close |
Close the linked pull request without merging it |
POST |
/v1/tasks/{id}/github/pull/reopen |
Reopen the linked pull request |
POST |
/v1/tasks/{id}/github/pull/comment |
{ body } — comment on the linked pull request |
POST |
/v1/tasks/{id}/github/pull/checks/rerun |
{ run_id } — re-run the failed jobs of one Actions run on its head |
{
"linked": true, "repo": "lezli01/vincent", "number": 412, "source": "auto",
"pull": { "number": 412, "state": "closed", "merged": true, "…": "…" }
}
GET /v1/tasks/{id}/github/pull/checks answers with one normalized row per
check on the pull request’s head commit:
{
"linked": true, "repo": "lezli01/vincent", "number": 412,
"ref": "d3adb33f…", "state": "failure",
"fetched_at": "2026-08-31T09:14:02Z",
"runs": [
{ "name": "test", "state": "in_progress",
"url": "https://github.com/lezli01/vincent/actions/runs/5150/job/72",
"run_id": 5150 },
{ "name": "license/cla", "state": "success", "url": "https://cla.example/…" }
]
}
state is one word, on the rollup and on each row: queued, in_progress,
success, failure, cancelled, skipped, neutral, timed_out,
action_required or stale. Failure wins over running on the rollup — a pull
request with one failed job and five still going is failed, and calling it in
progress would hide the only fact worth acting on. GitHub’s check runs and its
older commit statuses are folded onto the same shape, so the two credential
legs cannot be told apart. run_id is the GitHub Actions workflow run behind a
row and is absent for a third-party check run or a legacy commit status —
that is what distinguishes a row an operation like re-run could honestly be
offered on. A row also carries started_at and completed_at when GitHub
reported them. Nothing here is cached or stored: a check result a minute old
reads exactly like a current one while being wrong, so fetched_at is the only
honest answer to how old this one is.
Both GETs answer 200 whatever GitHub says. A task workspace asks it on every
open, and refusing the whole row because the integration is off would take a
fact vincent owns away from a client that can still render it — so the stored
link is always served, and reason carries the named vocabulary above when the
live fetch could not be made. It fetches by number rather than looking in the
listing, which is what lets a task still name a pull request that has since
merged and dropped off an open-only listing.
For a task with no link, the response carries compare_url instead: GitHub’s
own “open a pull request” page for the task’s branch, prefilled with the task’s
title and description plus Closes #N when the task carries an issue snapshot
from the same repository. It is built, never fetched — producing it makes no
request to GitHub — and it is the fallback behind
POST /v1/tasks/{id}/github/pull/create below.
Opening a pull request
POST /v1/tasks/{id}/github/pull/create writes to GitHub, as do the routes
that act on a linked pull request. It pushes
the task’s branch to origin and creates its pull request:
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Add rate limiting","body":"Closes #12","draft":true}' \
http://127.0.0.1:8765/v1/tasks/61/github/pull/create
It is gated by github.enabled and the same
capability probe every read is, and there is no second switch: the consent is a
person asking for it. It is deliberately not an
MCP tool — an agent that wants a pull request has a shell in its own
worktree and can run git push and gh pr create there.
The push uses --set-upstream and never --force. Only committed work
reaches it: anything uncommitted in the task’s worktree is not in the pull
request.
There are three answers, and only one of them is an error.
Created — 200, with the pull request and the task whose link was written
immediately as human, so nothing has to wait for the reconciler’s next tick:
{ "created": true, "pushed": true, "branch": "vincent/61-add-rate-limiting",
"remote": "origin", "pull": { "number": 412, "draft": true, "…": "…" },
"task": { "id": 61, "…": "…" } }
Pushed, not created — also 200, and also not a failure. There was no
credential with write scope, or GitHub refused the create — reason is one of
the reasons above, including pull_exists when a pull request
for the branch already exists and bad_request when GitHub rejects the values;
the branch is on the remote, so GitHub’s own page works and a client opens it
exactly as it would have before:
{ "created": false, "pushed": true, "branch": "vincent/61-add-rate-limiting",
"remote": "origin", "reason": "no_write_scope",
"message": "the credential may read this repository but not write to it",
"compare_url": "https://github.com/octo/repo/compare/main...vincent%2F61-add-rate-limiting?expand=1&title=…" }
Push refused — 409, and nothing was attempted at GitHub, because a pull
request for a head the remote does not have would be a dead page. The reason is
push_rejected (a non-fast-forward, a protected branch, a declined hook),
push_no_credential or push_failed. A task that already has a live link is
also 409, with pull_already_linked: unlink it first.
POST is the human link, for a pull request the head-branch rule misses (one
opened from a branch vincent did not create) or gets wrong. It writes vincent’s
own column and makes no GitHub call, not even to check the number exists —
validating it would put a network failure in the way of a person correcting
vincent, and a wrong number shows as not_found the moment it is displayed. A
human link is never overwritten by the daemon.
DELETE does not clear the column: it marks the link suppressed, keeping
the repo and number. That is what makes a human unlink stick — the daemon has to
be able to read the refusal, and an empty column would only say “never matched”.
POST and DELETE both return the updated task, as does a create that
succeeded.
Linking otherwise happens in the background. Every
github.poll_interval the daemon lists each
GitHub-based project’s open pull requests and links the ones whose head branch
equals a task’s branch, marking them auto. It never overwrites a human link
and never un-suppresses one. Set the interval to 0 to switch it off.
The two listings, POST /v1/tasks naming a github_issue, POST
/v1/tasks/{id}/github/pull and POST /v1/tasks/{id}/github/pull/create answer
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" } } }
Acting on a linked pull request
Five routes write to a task’s linked pull request, and to nothing else: the
number comes from the stored link and the repository from the project’s
origin, so a caller cannot aim one at a pull request the task does not name.
| Route | Body | 200 answer |
|---|---|---|
POST /v1/tasks/{id}/github/pull/merge |
{ method, head_sha } |
The pull request, re-read after the merge |
POST /v1/tasks/{id}/github/pull/close |
none | The pull request as the close left it |
POST /v1/tasks/{id}/github/pull/reopen |
none | The pull request as the reopen left it |
POST /v1/tasks/{id}/github/pull/comment |
{ body } |
{ url }, the new comment’s page |
POST /v1/tasks/{id}/github/pull/checks/rerun |
{ run_id } |
{ run_id } |
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"method":"squash","head_sha":"3f9c2e1d…"}' \
http://127.0.0.1:8765/v1/tasks/61/github/pull/merge
Each is gated like creation — github.enabled and the capability probe, with
the 409 above when the integration is not usable — and then refuses a task with
no live link, a suppressed one included, with 409 pull_not_linked. A body
that does not validate is 400 validation_failed: method must be merge,
squash or rebase and has no default, head_sha is required, a comment
cannot be empty, and run_id must be a positive run id.
A merge reads the pull request before it sends anything, and refuses —
merging nothing — with head_changed when the live head is not head_sha,
branch_behind when the repository requires an up-to-date branch and this one
is behind, checks_running when the merge is blocked on a check that has not
finished, or not_mergeable when the pull request is closed, already merged, a
draft, conflicted or blocked for another reason. The merge itself is pinned to
head_sha, so a push landing after that read is refused too. It never deletes
the branch, never queues an auto-merge and never uses an admin override.
A re-run re-reads the check rollup for the current
head and re-runs the failed jobs of run_id only when a failed row —
failure, timed_out, cancelled or action_required — carries that
run_id. A third-party check, a legacy commit status, a run that did
not fail or a run id from anywhere else is refused bad_request, and nothing is
sent.
A refusal from GitHub is 409 with details.reason from the
vocabulary above — a 403 is no_write_scope — and never
GitHub’s own text:
{ "error": { "code": "invalid_state",
"message": "could not merge octo/repo#412: the merge is blocked until the running checks finish",
"details": { "reason": "checks_running" } } }
There is no task-state guard, no idempotency key and no event: a running task’s
pull request may be merged, a second merge is refused not_mergeable, and a
comment sent twice posts twice. None of the five is an MCP tool.
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 |
GET |
/v1/workflows/schema |
The workflow schema as data: which fields are legal on which step type, and where each type may be nested |
POST |
/v1/workflows |
{ scope, project_id?, name, from?, from_project_id? } → the written file and its version. Creates a workflow; from forks an existing entry, keeping its name: so the copy shadows it |
PATCH |
/v1/workflows?name=&project_id= |
{ version, ops[] } → the same shape. Applies edit operations to a workflow file, preserving every byte outside the region they touch |
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[]?, version?, errors[]?, warnings[]?, error? }.
fields[] is the selected workflow’s ordered
fields: declaration. Each entry is
{ name, label?, description?, type, required, pattern?, values[]?, multiple?, default? };
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.
values[] is an enum field’s members in declared order, multiple says it
accepts more than one of them (joined with , in that order), and default is
the value that applies when the caller omits the key. All three are absent when
the declaration does not carry them. A client older than enum sees an unknown
type, falls back to a free-text row, and relies on POST /v1/tasks to reject
a non-member.
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.
version is the token a write of this entry must carry, described under
Writing a workflow. It is absent for a built-in, which
has no file to be stale against.
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]+$" },
{ "name": "environment", "type": "enum", "required": true,
"values": ["dev", "staging", "prod"], "default": "staging" }
],
"defaults": { "agent": "claude", "model": "sonnet" },
"steps": [
{ "id": "plan", "type": "agent", "prompt": "…", "check": "go build ./..." },
{ "id": "spread", "type": "fan_out", "schedule": "eager",
"lanes": [ { "id": "api", "steps": [ … ] },
{ "id": "web", "workflow": "web-feature", "needs": ["api"], "if": "…" } ],
"merge": { "on_conflict": "agent", "agent": { "id": "fixup", … } } }
]
}
}
schedule is a fan_out step’s lane scheduling mode — barrier or eager —
and is absent for a step that names none, which reads as barrier. Under
eager a lane is merged and its dependents spawned as soon as its own needs
are done and merged, which makes that lane’s starting tree timing-dependent;
barrier is the reproducible default. GET /v1/workflows/schema publishes the
two values as an enum, from the same constants the parser checks against.
A derived fan-out is the other shape this endpoint serves, and a client that
reads only lanes sees an empty step where one is: the registry entry carries a
single lane template in lane, the lists it will be rendered over in for_each,
and the ceiling on the result in max_lanes — with lanes absent, because the
list does not exist until the step runs (§7.6).
{ "id": "spread", "type": "fan_out",
"lane": { "id": "{{ .Item.name }}", "workflow": "unit",
"fields": { "target": "{{ .Item.name }}" } },
"for_each": ["{{ (index .Steps \"plan\").Result }}"],
"max_lanes": 8 }
Exactly one of lanes and lane is set in an authored file. Materialization
inverts them: a task’s snapshot of the same step carries the rendered lanes,
no lane and no for_each, and a derived_from record saying where the list
came from (below).
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 has one implementation. Clients report it; they never
re-implement the precedence. vincent workflow render resolves a file —
one this endpoint cannot serve, since it takes a workflow name the registry
has frequently not picked up yet — by calling that one implementation directly,
and reports the same {value, source}.
Writing a workflow
POST /v1/workflows creates a file, PATCH /v1/workflows edits one, and
neither carries YAML in either direction.
A create names a scope and a name — lowercase letters, digits, -, _ or
., starting with a letter or digit — and the daemon resolves the path itself:
{config_dir}/workflows/ for global, the project’s .vincent/workflows/ for
project, created when it does not exist yet, with {name}.yaml inside it. The
client never sends a path. Without from, the file is the starter template with
its name: set to name. With from, it is a copy of an existing entry’s
bytes and name decides only the file name: the copy keeps the source’s own
name:, because keeping it is what makes it shadow the original.
from_project_id says whose registry from is resolved against, when that is
not the project being written to. Built-ins have no file of their own, so
scope: builtin is a 400 — forking one into a scope that does have files is
the only way to change a built-in. New files are written 0644 — a project
workflow is meant to be committed and shared — and an existing file keeps the
mode it has.
A PATCH carries edit operations, which the daemon applies to the file’s own
bytes line by line. Comments, key order, blank lines, block scalars and CRLF
endings outside the region an operation touches come back byte-identical. That
is why the wire carries operations rather than a document: a client that had to
send the whole file would have had to discard its comments to build it, and the
daemon would then have nothing left to preserve.
{ "version": "…",
"ops": [
{ "op": "set", "path": "steps[2].prompt", "value": "…", "block": true },
{ "op": "insert", "path": "steps[1]",
"item": [ { "key": "id", "value": "review" },
{ "key": "type", "value": "agent" } ] },
{ "op": "remove", "path": "steps[0].model" },
{ "op": "move", "path": "steps[0]", "to": 2 } ] }
path is dotted with list indices — steps[2].prompt,
steps[3].lanes[0].merge.on_conflict, fields[1].values. block writes the
value as a | block scalar, which is what a prompt, a multi-line run: or an
instructions body needs. insert’s path carries the index the entry lands at,
and item is its keys in the order they should be written; the daemon renders
the YAML. remove takes either a key path or an indexed one — steps[0].model
drops the key, steps[3].lanes[0] drops the lane — and dropping a key is not
the same as writing an empty one, since an absent model: inherits the
workflow’s defaults and model: "" does not. move reorders within one
sequence.
A create answers 201 and a patch 200, both with
{ name, scope, file, version, errors[], warnings[] } — the parse verdict on
the bytes now on disk. The two differ in what they will let you produce. A
PATCH the daemon cannot apply, or whose result does not parse, is a 400
naming the findings and nothing is written: the file is byte-identical to
what it was before the request, the same rule
PATCH /v1/config follows. A result over the 1 MiB a workflow source
is bounded to is a 413, and equally writes nothing. A create can still answer
with a populated errors[], because a fork copies bytes verbatim and the entry
you forked may already be broken — which is the same reason the registry lists a
broken file instead of hiding it.
version is a precondition. It is an opaque token for a file’s current
contents, served on GET /v1/workflows and GET /v1/workflows/definition and
returned by every write. A PATCH carrying a stale one is a 409 with the
current token in details.version, and nothing is written. This is scoped to
workflows deliberately: PATCH /v1/config carries no precondition
because there the race is a human against themselves, while a workflow file has
writers who are not the person at the keyboard — the create-workflow built-in
writes the live registry directory from an agent run, and $EDITOR is one key
away in the same view. A create is refused the same way when the destination
file already exists, or when another file in the target scope already declares
that name: — the duplicate the registry would otherwise have to pick between.
GET /v1/workflows/schema is the workflow schema served
as data: the top-level, defaults, fields[], lane and merge rows, and per
step type the fields it accepts, which of the common fields it accepts, and
which contexts (body, parallel, loop, merge) it may appear in. It is
generated from the same table the parser validates against, so a client renders
forms from it instead of carrying a second copy of the rules — which is how the
two used to drift. Each row names a control telling a client what to draw
(string, text, enum, bool, int, duration, list, map, agent,
model, effort, steps, lanes, lane, merge, workflow, fields,
template); an unrecognized one is drawn as a text row, which is what keeps an
older client usable against a newer daemon. Agent, model and effort value sets
are not here — those come from GET /v1/agents.
Triggers
| Method | Path | Notes |
|---|---|---|
GET |
/v1/triggers |
Every file under {config_dir}/triggers/, broken ones included → { enabled, dir, triggers[] } |
POST |
/v1/triggers |
{ id, project_id, poll_interval?, command[]?, workflow?, title? } → 201 with the written file and its version. Writes a disabled starter |
GET |
/v1/triggers/schema |
The trigger schema as data: top-level fields, the source and action variants, and which values are dangerous |
POST |
/v1/triggers/validate |
{ source, id? } → { valid, errors[] }. Writes nothing |
GET |
/v1/triggers/{id} |
The list row plus source and definition |
PATCH |
/v1/triggers/{id} |
{ version, ops[] } → the create’s response shape. Applies edit operations to the file |
DELETE |
/v1/triggers/{id}?version= |
→ 204. Removes the file and keeps its ledger |
POST |
/v1/triggers/{id}/test |
{ event } → a judgement. Writes nothing |
POST |
/v1/triggers/{id}/poll |
→ { seed, events[], truncated, refused, cursor?, error? }. Runs the source once and records nothing |
GET |
/v1/triggers/{id}/deliveries?limit= |
The ledger, newest first → { deliveries[] } |
POST |
/v1/triggers/{id}/events |
The type: http ingress: one signed event → its delivery. See Pushing an event |
A trigger is a file, {config_dir}/triggers/{id}.yaml, that polls a command or
GitHub, accepts a signed push, or comes due on a schedule, and creates or acts
on a task for each new event that passes its filter. There is no project-scope
directory. The definition is the file; the trigger’s cursor, poll health and
ledger live in the database. A trigger does anything only when it is
armed: its file validates, its own enabled: is true, and the global
triggers.enabled is on. That key is switched over
PATCH /v1/config. With it off, every route here still answers:
the list shows each trigger disarmed, and both dry runs work.
GET /v1/triggers answers { enabled, dir, triggers[] }, where enabled is
triggers.enabled and dir is the directory the daemon watches. Each row is:
{ "id": "ci-failures", "file": "/home/you/.config/vincent/triggers/ci-failures.yaml",
"version": "…", "valid": true, "errors": [],
"enabled": true, "armed": false,
"disarmed_reason": "triggers.enabled is off in config.yaml",
"source_type": "command", "action_type": "create_task", "project_id": 3,
"on_fire": "propose", "permission": "restricted",
"poll": { "seeded": false, "last_poll_at": null, "ok": false, "last_fire_at": null } }
- A file that does not validate is listed with its
errors[], never hidden — the same findings a workflow entry carries.source_type,action_type,project_idandon_fireare absent on such a row, because there is no parsed definition to read them from. armedisvalid,enabledandtriggers.enabledtogether.disarmed_reasonnames the first one missing, in that order, as a sentence for a person:the trigger file does not validate,the trigger is disabled, ortriggers.enabled is off in config.yaml. It is absent on an armed trigger.on_fireandpermissionare the values in effect, not the values written: a file with noon_fire:readspropose, and one with nopermission:readsrestricted.permissionis present only on acreate_taskaction.pollis the trigger’s poll health.seededsays it holds a cursor, so its next poll judges events instead of seeding.okanderrorare the last poll’s result.last_poll_atandlast_fire_atarenulluntil each has happened. A trigger that has never polled carries the zero values shown above. Atype: schedulesource has no poll:seededsays its clock is anchored, andlast_poll_atis when that anchor was last written, by the arming tick or by a fire.
GET /v1/triggers/{id} is the row plus source, the file’s bytes, and
definition, the parsed document as JSON. A file that does not validate is a
200 with definition: null, the rule
the workflow definition follows. A 404
means no file by that id.
Writing a trigger
Trigger writes follow Writing a workflow: YAML crosses
the wire in neither direction, an edit is a list of operations, and version
is a precondition.
Create. id is lowercase letters, digits, -, _ or ., starting with a
letter or digit and never containing ..; anything else is
400 validation_failed. project_id is
required, and a project that does not exist is a 404. The daemon renders the
file itself: a type: command source running command every poll_interval,
a create_task action with workflow and title, enabled: false, and no
on_fire line, so it proposes until someone writes otherwise. Each absent
input gets a placeholder that validates — 5m, /path/to/poll-command, and
{{ .Event.id }} for the title — so the starter is a working file for a form
to edit. An input that does not validate is a 400 carrying the findings. A
file already at that id is a 409. The answer is 201 with
{ id, file, version, errors[] }.
Patch. The same { version, ops[] } as
PATCH /v1/workflows, with the same operations and the
same 512-operation bound, answered 200 in the create’s shape. A stale
version is a 409 with the current token in details.version. An operation
the daemon cannot apply, or a result that does not validate, is a
400 validation_failed whose details.errors holds the findings as a
JSON-encoded string. Either way the file is byte-identical to what it was.
That is also how an edit changing id away from the file’s name is refused.
Delete. ?version= is required: without it the answer is a 400, and with
a stale one a 409 carrying details.version. The file goes, and so does the
trigger’s cursor. Its ledger is kept, so re-creating the id cannot fire
again an event the old trigger already delivered.
Every write leaves the file 0600, whether it was new or not. A trigger’s argv
may carry a secret, and no repository owns the file. That is config.yaml’s
rule, not a workflow’s. An id that could not name a file is a 404 on PATCH
and DELETE.
POST /v1/triggers/validate parses source without writing anything. id,
when given, is the file stem the document’s own id must match.
GET /v1/triggers/schema is the trigger schema served as data, the way
GET /v1/workflows/schema serves a workflow’s:
{ top_level[], sources[], actions[], limits[], signature[] }. A field row is
{ name, control, values[]?, required?, default?, help?, dangerous[]? }, and
a variant is { type, fields[], help?, events[]?, trusted[]? }. The source
types are command, github_issues, github_prs, http and schedule. The action types
are create_task and the three reactions, follow_up, retry and cancel.
On a GitHub source, events[] names the events it emits, and trusted[] the
ones a trigger may match without naming allowed_actors. control adds
source, action, limits, signature, match, project and number to
the workflow schema’s vocabulary. dangerous[] lists { value, warning }
pairs a client confirms before committing — enabled: true,
on_fire: create, permission: workflow and overrun: cancel_previous — and
shows the warning when it asks. The first three take a keypress out of starting
agents; the fourth destroys work in flight.
vincent trigger apply refuses the first three
on a file it did not already hold them on, and only those: cancel_previous
arms nothing that was not already armed.
Dry runs
Both dry runs use the pipeline a real event goes through, and neither writes
anything. Both work while the trigger or triggers.enabled is off. A file that
does not validate is a 400 with its findings in details.errors.
POST /v1/triggers/{id}/test judges the event you supply, which must be a
JSON object. It runs match:, allowed_actors, if:, the dedupe lookup, the
rate limit, the action’s templates and the overrun: check, and returns the
judgement:
{ "event_id": "build-4812",
"matched": true,
"if": true, "if_rendered": "true",
"dedupe_key": "build-4812", "would_dedupe": false,
"action": { "type": "create_task", "method": "POST", "path": "/v1/tasks",
"body": { "project_id": 3, "title": "CI failed: build-4812",
"paused": true, "restricted": true } },
"outcome": "fired" }
matchedis thematch:verdict.match_missnames the first key that failed, orallowed_actorsfor an author the list does not name.ifis the guard’s verdict andif_renderedwhat it rendered to.ifis absent when the trigger has no guard or the guard was not reached.dedupe_keyis the rendered key.would_dedupesays the ledger already holds afiredorseededrow for it.overrunis the mode the trigger declared, absent for theparalleldefault, which is not checked at all.concurrency_keyis the rendered group,in_flightthe unsettled tasks found in it, andwould_skip,would_cancelandwould_queuethe decision — one of them at most, and none when the group is empty. The check runs last, after rendering and after a reaction’s target resolution, because a reaction’s group is the task it resolved to. Like the rest of the dry run it writes nothing: no ledger row and no held event.actionis the request the trigger would replay, unsent:type,method,pathandbody. Forcreate_taskthe body is aPOST /v1/tasksbody,paused: trueunlesson_fire: createandrestricted: trueunlesspermission: workflow. A reaction also carries thebranchit rendered and thetask_idit resolved, with that id inpath. Its body is a/follow_upor/retrybody, likewisepausedunlesson_fire: create, and acancelhas none.actionis absent when the pipeline stopped before rendering, or rendering failed.outcomeis the ledger outcome the event would get. Herefiredmeans “would be replayed”: a dry run stops short of the replay, so it cannot know whether the route would refuse.errorsays why the pipeline stopped: a template orif:that failed to render, an event with noidon a trigger with nodedupe_key, or a reaction target that did not resolve.
POST /v1/triggers/{id}/poll runs the source once for real — the command,
or a GitHub listing — and judges each event it returns as /test does. It
fires nothing, advances no cursor, writes no ledger row and leaves poll health
alone, but the command itself runs with whatever effects it has.
seedsays a real poll right now would seed and fire nothing. A command’s events are judged anyway, to show what the filter will do once the trigger is armed. A GitHub source has no events to judge until its snapshot exists, so its seeding poll answers an emptyevents[].truncatedcounts events past the 20-event cap on one poll, which were not judged.refusedcounts command output lines that were not events.cursoris the watermark the command printed, which a real poll would store.- A command that fails or times out, or a listing that fails, is still a
200:errorsays why,events[]is empty and the other fields are zero. - A
type: httptrigger is pushed and atype: scheduletrigger is a clock: neither has a source to run once, so both are a400. Judge a supplied event with/testinstead.
The delivery ledger
GET /v1/triggers/{id}/deliveries?limit= reads one row per event the trigger
judged, newest first. limit is 1 to 1000 and defaults to 100; anything else is
a 400.
{ "deliveries": [ {
"id": 57, "trigger_id": "ci-failures", "event_id": "build-4812",
"dedupe_key": "build-4812", "concurrency_key": "build", "outcome": "fired",
"task_id": 142, "superseded_task_id": 140,
"created_at": "2026-09-13T09:30:00.000000000Z" } ] }
outcome |
Meaning |
|---|---|
fired |
The replayed route created or acted on a task, and task_id names it |
seeded |
The first poll after arming was shown the event. Nothing fired, but it dedupes like fired, so a source that keeps no cursor of its own does not fire its backlog on the next poll |
deduped |
A fired or seeded row already holds its dedupe key |
filtered |
match:, allowed_actors or if: rejected it |
rate_limited |
It was over limits.max_per_hour, which counts fired rows alone. Dropped, not queued |
refused |
The replayed route answered 4xx. detail keeps that answer’s error envelope, and a reaction’s task_id names the task it targeted. A reaction whose branch matched no unarchived task is refused too, with nothing replayed: detail names the branch and task_id is null |
error |
A template or if: failed to render, the event had no id and the trigger no dedupe_key, or the replay answered 5xx or never reached the route. detail says which |
superseded |
overrun: dropped it in favour of work already in flight or of a newer event: skip’s drop, a queue_coalesce drain’s discards, an event its group already held, a held event a disarm threw away, or the oldest held event at the 100-per-trigger cap. detail says which |
queued |
A queue mode is holding it in the backlog until its group empties. It is deliberately not delivered, so a second identical event reaches the overrun step rather than being deduped; it gets a second row when it fires |
concurrency_key is the overrun: group the row was judged in, absent for a
trigger that declares none. superseded_task_id is the task an
overrun: cancel_previous fire replaced — the chain a rapid-fire source leaves
behind — and, like task_id, becomes null if that task is permanently
deleted.
task_id is null when no task was involved, and becomes null if that task
is later permanently deleted. The ledger outlives the
file: an id with no file still lists its rows, and only an id that could not
name a file is a 404.
A create_task replay carries an Idempotency-Key the daemon derives from the
dedupe key — trigger:{id}: followed by a hash. Two triggers, or a trigger and
a script sending its own key, therefore never replay each other’s tasks. A task
a trigger creates is otherwise an ordinary task.
Pushing an event
POST /v1/triggers/{id}/events is how a type: http trigger receives an event,
and it is authenticated twice. It needs the bearer token like every other
route, then the trigger’s own signature. Under the one scheme,
github_hmac_sha256, that is X-Hub-Signature-256: sha256={hex}: an
HMAC-SHA256 of the raw body, keyed by the value of the environment variable the
trigger’s signature.secret_env names. The daemon reads that variable when the
request arrives and compares in constant time.
The signature covers the bytes as sent, so this route reads its body raw,
under the 4 MiB tier, and parses it only after the signature verifies. The
event’s id is the body’s string id; failing that, it is the
X-GitHub-Delivery header. A body with neither is refused. A webhook sent
straight from GitHub.com cannot add the bearer header, so it cannot deliver:
only a caller on this machine that can read {data_dir}/token and holds
the secret can.
BODY='{"id":"build-4812","status":"failed"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$CI_TRIGGER_SECRET" | awk '{print $NF}')"
curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIG" --data-binary "$BODY" \
"http://127.0.0.1:$PORT/v1/triggers/ci-failures/events" | jq
Refusals come in this order, and none of them writes a ledger row:
| Status | When |
|---|---|
404 not_found |
No trigger by that id |
413 payload_too_large |
The body is over 4 MiB |
400 validation_failed |
The trigger’s source is not type: http |
409 invalid_state |
The trigger is not armed, including a file that does not validate. details.reason is its disarmed_reason |
401 unauthorized |
The signature is missing or wrong, or the daemon’s environment does not set the secret variable. The three are indistinguishable on purpose |
400 validation_failed |
The body is not a JSON object, or has no string id and the request no X-GitHub-Delivery |
Past those, the answer is a 200 carrying the delivery: the judgement above
plus { delivery_id, task_id?, detail? }. Its outcome is the one actually
recorded, so a push the replayed route refused is still a 200, with
outcome: refused. A push has no per-poll cap, and pushes are judged one at a
time.
Tasks
| Method | Path | Notes |
|---|---|---|
GET |
/v1/tasks?project_id=&state=&archived=&archived_before=&archived_since=&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?, existing_branch?, priority?, agent?, model?, effort?, github_issue?, github_pull?, paused?, restricted?, max_task_cost_usd? } — branch_name is used verbatim and wins over any template, except on a github_pull task, whose branch is the pull request’s head. existing_branch runs the task on a branch that already exists — see Running on an existing branch. paused, restricted and max_task_cost_usd are the create-time limits. Accepts an optional Idempotency-Key header |
GET |
/v1/tasks/{id} |
Full task |
PATCH |
/v1/tasks/{id} |
{ priority } — queued/paused only |
DELETE |
/v1/tasks/{id} |
Permanent delete of an archived task. ?delete_branch=true (or { "delete_branch": true }) → { deleted: true, branch? }. See Permanent delete |
POST |
/v1/tasks/import |
{ path, task_id, project_id? } — one archived task back out of a backup archive. See Importing a task |
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) 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 — plus loop_total, how many iterations the admission that wrote the row planned to run (the count:, or the resolved for_each list’s length; 0 outside a loop and on a row written before the daemon recorded it). A fan_out step with needs: between its lanes puts its rounds on the same iteration column (0-based, so a flat lane list still reads 0), which is the one other place a non-zero iteration appears — under schedule: eager that number is a monotonic merge counter rather than the lane’s wave, and the step may write up to one merge row per lane; the two cannot be confused because a fan_out is not valid inside a loop body. A fan_out row appears when its round’s lanes are spawned, not when they merge: the park opens the row running and that round’s merge admission finalizes the same one (§7.6), so a parent whose lanes are working is on the timeline rather than missing from it. Each row also carries what the attempt was given: rendered_prompt, rendered_run, rendered_check and rendered_if are the substituted text the adapter, the shell and the guard actually saw — the full bytes, unlike prompt_override/run_override, which are booleans here — with rendered_for_each the resolved list an iteration drew its loop_item from, carried as a string holding a JSON array rather than as an array field, and input_truncated saying a field was cut at its 64 KiB ceiling. Beside them the resolution the attempt ran under: agent_source, model_source and effort_source name which level supplied each part (step, task, workflow, adapter), and permission_mode, timeout_ms, check_timeout_ms, shell and work_dir are the values that were in force, recorded rather than re-resolved, so they still describe the attempt after a config reload or a task patch. null (or 0, or "") means nothing was recorded — an attempt from before the daemon recorded any of this, and every field the step type has no input for — while an empty string on a rendered field is a render that produced nothing. rendered_if is evidence, not a decision: a guard is re-evaluated every time it is reached |
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 |
GET |
/v1/tasks/{id}/workflow |
This task’s own workflow snapshot as a full definition — what ran, not what the registry says now. See The task’s workflow |
Running on an existing branch
existing_branch: true on POST /v1/tasks (and on POST /v1/chats, which
takes branch_name with it) runs the task on the branch branch_name resolves
to instead of cutting it. Without the field nothing changes: a branch that
already exists is still the creation-time 400 and the admission-time
branch_exists block. Adoption is asked for, never inferred from a branch
happening to exist.
The branch is refreshed from its own upstream, never an assumed origin:
behind is fast-forwarded, ahead is left exactly where it is, diverged blocks
adopt_branch_diverged with no
ref moved, and a branch with no upstream is neither fetched nor given one. If
the branch is already checked out in the project itself, the task runs
there — worktree_path is the project path, no worktree is created, archiving
removes nothing, and GET /v1/tasks/{id}/diff is that directory’s diff, so
uncommitted work the human already had reads as part of it. A branch held by
one of vincent’s own worktrees is refused at admission
(adopt_branch_checked_out) instead.
Every task and chat representation carries adopted_branch, because two things
a client shows follow from it and cannot be derived: archive will never delete
this branch (both legs report not_ours), and a task whose worktree_path is
the project path is running in the human’s own checkout.
| Status | When |
|---|---|
400 |
existing_branch with no such local branch — a courtesy check, with admission still the authority |
400 |
existing_branch together with github_pull: a pull-request task already runs on the pull request’s head branch |
400 |
On POST /v1/chats: branch_name without existing_branch, or existing_branch without branch_name |
409 |
On POST /v1/chats: that branch is already being worked on. details carries branch and claimed_by |
At most one unarchived owner works in one directory. A task whose directory
is taken is created and waits queued — the scheduler skips it and
reconsiders on the next walk, the way it skips a project at its cap, so nobody
has to retry it. A chat is created synchronously and has nowhere to wait, which
is what the 409 is.
Permanent delete
DELETE /v1/tasks/{id} and DELETE /v1/chats/{id} remove an archived row
for good: the row, its step attempts (or its turns) and its transcript
directory under {data_dir}/transcripts/. A task’s staged trigger proposal
under {data_dir}/trigger-proposals/ goes with it, and so does its staged
global workflow proposal under {data_dir}/workflow-proposals/. They are the
only routes that delete
a task or chat row — retention removes transcript
files and never a row.
Delete is not a §6 action. It never appears in available_actions, the
state check is the handler’s own rather than the FSM’s, and neither route is an
MCP tool. The precedent is DELETE /v1/projects/{id},
which is likewise no action.
?archived_before= and ?archived_since= on GET /v1/tasks and
GET /v1/chats are RFC3339 instants that bound the listing to what was
archived in a window; anything unparseable is 400 validation_failed. An
archived-only listing comes back newest-archived first rather than by id —
recency is the only order an archive has, which is why there is no sort=
parameter. Tasks measure the bound over archived_at; chats measure it over
updated_at, which for a terminal chat is when it ended.
There is no bulk delete and there is not going to be one. A sweep is one
DELETE per row.
curl -sS -X DELETE "http://127.0.0.1:PORT/v1/tasks/7?delete_branch=true" \
-H "Authorization: Bearer $TOKEN"
{ "deleted": true,
"branch": { "name": "vincent/7-add-login", "result": "has_commits" } }
branch carries archive’s vocabulary unchanged — deleted, has_commits,
not_ours, unknown, error — and is omitted entirely when the branch step
did not run. delete_branch=true does not weaken §10’s standing rule: a
branch carrying any commit past its base is kept and reported has_commits
whatever was asked for. The remote branch is never touched; that leg belongs to
delete_remote_branch_on_archive and to archive alone.
Every refusal is a 409 in the standard envelope, with details.reason naming
what is holding on:
details.reason |
Meaning |
|---|---|
not_archived |
The row is still live. Delete applies to archived rows only |
has_lanes |
An archived fan-out parent whose lane rows still exist. Delete the lanes first |
handoff_target |
A handed_off chat points at this task and would be left pointing at nothing |
handed_off |
The chat was handed off to a task, which owns its worktree and branch. Delete that task |
A chat opened on a task counts as archived once it
is closed, so it can be deleted too. delete_branch=true on such a chat, in
any state, is refused with the code chat_linked_to_task and details.task_id:
the branch is the task’s. Deleting the task instead deletes its closed chats and
their transcripts along with it.
An unknown id is 404.
Two things a delete deliberately does not do. It does not purge the row’s
events: their id is the Last-Event-ID cursor every subscriber is holding,
and one archived row going is not a project’s whole history going. And it does
not clear created_by_task_id on a task this one created by refusing — that
column nulls itself, and mcp.max_depth’s walk simply stops one link early.
A client resuming /v1/events from a cursor older than a delete will see events
for an id that now 404s. That is the same thing a project delete does to a
stale cursor, and it is survivable: re-fetch, and drop what is gone.
Importing a task
POST /v1/tasks/import is the undo for a permanent delete: it copies one task
out of an archive POST /v1/daemon/backup wrote — the row, its step
attempts and its transcript directory — into this installation, in one
transaction that appends a task.restored event. What
vincent task import calls.
curl -sS -X POST http://127.0.0.1:PORT/v1/tasks/import \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"path": "/home/me/vincent-2026-09-01.tar.gz", "task_id": 12}'
{ "task_id": 12, "project_id": 3, "title": "Add login",
"step_runs": 4, "step_runs_renumbered": false,
"transcript_files": 4, "transcript_bytes": 1234,
"archived_at": "2026-09-17T12:00:00.000000000Z",
"backup_schema_version": 31, "backup_created_at": "2026-09-01T08:00:00.000000000Z" }
path must be absolute. The task keeps its id and comes back archived, with
archived_at set to the import time so retention
starts over. worktree_path is cleared and created_by_task_id is cleared
unless that task exists here; every other column is copied as it is. Step
attempt ids are all kept when all are free, and all renumbered in their original
order when any is taken — step_runs_renumbered says which. project_id
imports into another project; without it the backed-up project must exist here
under the same id and the same name. The task’s historical events are not
copied. An archive from an older schema imports: the daemon migrates a staged
copy of its database, never the live one.
| Status | details.reason |
Meaning |
|---|---|---|
400 |
— | path is missing, relative or not a regular file; task_id is not a positive id; the file is not a vincent backup, or has no database or one that cannot be opened; an entry is unsafe |
400 |
schema_too_new |
The archive was written by a newer schema than this daemon’s |
404 |
task_not_in_backup |
The archive has no task with that id |
404 |
project_not_found |
project_id names no project |
409 |
task_exists |
A task here already holds the id |
409 |
not_archived |
The task was not archived in the backup; details.state is the state it was in |
409 |
project_mismatch |
No project here matches the backed-up one by id and name |
409 |
parent_missing |
A fan-out lane whose parent is not here. Import the parent first |
409 |
transcripts_present |
{data_dir}/transcripts/{task_id}/ already exists. Nothing is merged or deleted |
The 409s carry details.action: "import". There is no bulk import; the route
is not an MCP tool.
The task’s workflow
GET /v1/tasks/{id}/workflow serves the task’s own workflow snapshot with the
same structure GET /v1/workflows/definition serves for a registry entry — one
DTO describes both. It is what the TUI’s Workflow tab draws.
It is deliberately not the registry entry of the same name. The snapshot is what
the task actually ran: include steps are
already spliced flat, and an edit + retry rewrite is reflected. The registry’s
copy is whatever the file says right now, which for a running task may be a
different workflow entirely.
curl -sS "http://127.0.0.1:$PORT/v1/tasks/12/workflow" \
-H "Authorization: Bearer $TOKEN"
{
"task_id": 12,
"name": "ship-it",
"error": null,
"definition": {
"name": "ship-it",
"fields": [],
"defaults": { "agent": "claude" },
"steps": [
{ "id": "plan", "type": "agent", "prompt": "plan it" },
{ "id": "lint", "type": "command", "run": "go vet ./...",
"resolved_from": ["go-checks"] }
]
}
}
resolved_from names the chain of workflows a step was spliced through,
outermost first — after a splice there is no include step left to attribute
it to, so the step carries it.
derived_from is the other field only a snapshot has. A fan_out step whose
lanes were derived at run time from for_each: carries the lane list it
produced in lanes and a record of what produced it beside them:
{ "id": "spread", "type": "fan_out",
"lanes": [ { "id": "auth", … }, { "id": "billing", … } ],
"derived_from": { "lane": "{{ .Item.name }}",
"for_each": ["{{ (index .Steps \"plan\").Result }}"] } }
Only the lane: id template and the for_each: templates are kept — the rest
of the lane template is already visible on every lane it produced. It is what
lets a client tell a derived lane list from a hand-written one after
materialization, which is otherwise lossy; the TUI’s Workflow tab marks the
frame with it. A registry entry never carries it, GET /v1/workflows/schema
does not publish it, and a workflow document that declares it is rejected the
way an unknown key is (§7.6).
The envelope carries no scope, file, platforms or platform_supported:
those are registry facts a snapshot has none of. Where the task’s definition
came from is workflow_origin on GET /v1/tasks/{id}.
A snapshot that does not parse is a 200 with errors[] and
"definition": null, never a 4xx — the same rule
/v1/workflows/definition follows.
Replaying a create
POST /v1/tasks is the one route where re-sending a request does something
twice: it inserts a task, claims a branch and wakes the scheduler. Send an
Idempotency-Key header to make the retry safe.
KEY=$(uuidgen)
curl -sS -X POST "http://127.0.0.1:$PORT/v1/tasks" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"project_id":1,"title":"Add login page"}'
# lost the response? send exactly that again — same key, same body.
- Same key, same body →
201with the task the first request created. The body is the task as it is now, not a recording of the original response, so a task the scheduler has already admitted replays as"state": "running"under a201. The task exists, and this is it. - Same key, a different body →
409 invalid_statewithdetails.reason: "idempotency_key_reused", and no task is created. Use a new key for a new operation. - No key → exactly the behaviour vincent has always had. Two identical sends make two tasks, which is what a person pressing enter twice means.
The body is compared by a digest of the decoded request, so reformatting your
JSON or reordering its keys between the two sends is not a difference. It is
taken before the github_issue prefill runs, so an issue edited in between does
not turn a genuine retry into a conflict. paused, restricted and
max_task_cost_usd are part of it — the same key with a different value for any
of them is idempotency_key_reused — and a body that names none of them
digests exactly as it did before they existed.
Keys live for 24 hours and are then pruned; they are also deleted along with the
task they name, so a key whose task was destroyed by a forced project delete
creates a fresh task on the next send. vincent doctor counts them under
database.table_rows.idempotency_keys.
The CLI and the TUI do not send the header: neither retries a create, so a failed create is reported to you and what to do next is your call.
On POST /v1/tasks, the daemon validates the selected root workflow’s
declared fields before inserting the task. Missing required values, invalid
types or patterns, and values outside an enum’s values[] 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.
Two things happen before that validation. A multiple: true enum value is
normalized — split on ,, trimmed, emptied entries dropped, deduplicated,
reordered to the declared order and rejoined — so "reviewers": "cy, ana" is
stored as "ana,cy" and every client produces the same task row for the same
selection; a rejection names the element that failed. And a required field’s
default: is substituted for an omitted key, so a caller that omits it gets a
task rather than a 400, with the applied value recorded on the row. An
optional field’s default is never substituted: it is published in
GET /v1/workflows for clients to seed, and an optional key you omit stays
absent from the task’s fields. A key sent with an empty value is never
defaulted.
A declared key whose value is blank — "", or only whitespace — is dropped
in the same pass, after that substitution. The task is stored as if the key had
never been sent: an optional field is neither type-checked nor recorded, a
required one still returns 400 validation_failed with “is required”, and the
New task form reads the same value as absent and says the same thing. The open
half of the map is unaffected — an undeclared key with a blank value is stored
as you sent it.
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 — a blank declared field is then dropped from the
stored map as above, and either way the issue does not fill it — 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.
github_pull is a pull-request number, and it resolves through the same one
implementation on the same presence/blank rules — the daemon fetches the pull
request, prefills title (#N and the pull request title), description (its
body plus a trailing GitHub pull request #N: <url> line) and a declared field
named exactly pull carrying the bare number, and anything sent explicitly wins.
Naming both github_issue and github_pull is 400 validation_failed: they
would prefill the same title and description from two sources.
What is different is the branch. The task’s branch_name is the pull request’s
head branch — the top of the branch chain,
above even a literal branch_name, which is ignored — and its worktree is that
branch checked out with an upstream, so a workflow that pushes lands its commits
on the pull request. Four consequences follow:
- The creation-time branch-collision check is skipped: the branch is expected
to exist. The in-transaction claim check still runs, so two live tasks on one
head branch remain a
400. - The
github_pulllink is written at creation withsource: "human", so the pull request reads as claimed immediately rather than on the next reconciler tick, and the reconciler will not overwrite it. The link carriesbranch: true— this task’s branch came from this pull request — andfork: truewhen the head lives in another repository, in which case the branch has no upstream and nothing can be pushed back. - Archiving deletes neither branch leg; the outcome is
not_ours(below). POST /v1/tasks/{id}/retryrefusesbranch_overridewith a409.
Fetching the head is admission’s job, not creation’s: POST /v1/tasks runs no
git at all. A fetch that fails, a diverged local branch of that name, or one
already checked out elsewhere block the task with
pull_fetch_failed, pull_branch_diverged or pull_branch_checked_out.
Every task representation also carries workflow_origin: which definition the
workflow name resolved to at creation.
"workflow": "adhoc",
"workflow_origin": {
"scope": "project",
"file": ".vincent/workflows/adhoc.yaml",
"digest": "sha256:0f4a1c1e…"
}
A project or global workflow shadows a built-in of the same name, including
the adhoc a task created without a workflow falls back to. That is by
design, and this field is how you see it happened: scope is builtin,
global, project or derived, file is the source path relative to that
scope’s root (absent for a built-in), and digest is a SHA-256 of the source
bytes as the registry loaded them — for a built-in, the copy compiled into the
binary.
It is frozen at creation and never recomputed, so editing the workflow file
afterwards does not rewrite an existing task’s origin — and the digest names the
file the task came from, not the (expanded, possibly edit + retry-rewritten)
workflow_snapshot the engine executes. A fan_out lane reports
{"scope": "derived", "parent_task_id": 41}: its steps came from its parent’s
snapshot, not from a registry file. A task created before vincent recorded this
has "workflow_origin": null, which means not recorded — vincent will not
look the name up again to invent one.
The task.created event carries the same object under workflow_origin.
Every task representation also carries where the task started, and how its base was refreshed first:
"base_branch": "master",
"base_sha": "1a2b3c4d5e6f…",
"base_refresh": {
"fetch": {"result": "fetched", "remote": "origin", "ref": "refs/heads/master"},
"fast_forward": {"result": "skipped", "reason": "checkout_dirty"}
}
base_sha is the commit the task branch was cut from, and is absent when the
branch was cut from the local base_branch itself. base_refresh is recorded
once, when the scheduler first admits the task and creates its worktree. It is
null before that, for a task whose worktree predates the record, and for a task
created from a pull request, which refreshes no base.
fetch.result is fetched, no_upstream, disabled
(fetch_base_branch: false) or error —
the task started from the local branch, which may be stale, and error carries
git’s message. fast_forward.result is what then happened to your local base
branch: advanced (it moved to the fetched commit, with its checkout at
worktree when it has one), up_to_date, not_attempted (nothing was fetched),
or skipped with a reason of local_ahead, diverged, checkout_dirty,
checkout_busy or error. None of these is a failure; the task runs either way.
A chat carries the same two fields, its worktree is created under the same
fetch_base_branch key, and POST /v1/chats/{id}/handoff copies both to the
task.
Human actions, all POST /v1/tasks/{id}/…:
| Path | Valid from | Body |
|---|---|---|
/cancel |
most states | |
/pause |
queued, running | |
/resume |
paused | |
/retry |
blocked, awaiting_children | { prompt_override?, run_override?, branch_override?, paused? } — paused holds the task instead of re-queuing it. branch_override renames the branch before re-admission, which is how a branch_exists block is recovered. 409 on a task created from a pull request: renaming its branch would detach it from that pull request. From awaiting_children it means the cascade below, and all three overrides and paused are a 400 |
/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?, fields?, paused? } — exactly one of the three; fields apply to this run only, over the task’s own; paused holds the task instead of queuing the run; runs it in the task’s existing worktree, then returns the task to the state it came from |
/chat |
blocked, awaiting_gate, done, aborted | { title?, agent?, model?, effort? }, or no body — opens a chat linked to the task in its worktree, and locks the task until the chat is closed. 201 with the chat; the task does not move |
Anything else returns 409 with details.state. See
Task lifecycle.
While a linked chat is open the task is locked: every action above except
/cancel is 409 with the code task_locked_by_chat and details.chat_id
naming the chat to close — a second /chat included. /retry with
branch_override is refused before the branch is renamed. /cancel on a locked
task stops the chat’s turn, closes the chat and aborts the task in one step.
Holding a retry or a follow-up
"paused": true on /retry or /follow_up holds the task: it lands in
paused instead of queued, and /resume is what admits it. Everything else
the action writes is written now. A retry still resets the step’s retry budget
and applies its override. A follow-up still records its request and the state
it will return to. It is the hold POST /v1/tasks offers at creation, applied to
a task that already exists, so no agent can start between the action and a human
looking at it.
A held retry on a blocked parent holds the blocked lanes its cascade reaches
too, and retried_descendants counts them. A held call starts nothing. From
awaiting_children a held retry is 400 validation_failed: that retry
re-admits the lanes and never queues the parent, so there is nothing to hold.
Where the action itself is not valid, a held request gets the same 409 a plain
one does. Omitting paused, or sending false, is the plain action.
A held follow-up runs and ends like any other once resumed: done back to
done, aborted back to aborted. Cancelling it while it waits drops the
follow-up and leaves the task aborted, as /cancel does to any follow-up
that has not finished.
/retry on a fan_out parent parked in awaiting_children is a cascade:
the parent has no step of its own to re-run, so the one call re-admits every
blocked descendant beneath it, at any depth, and the parent’s own row is not
written — it comes back still awaiting_children, and the join closes on its
own once the lanes finish. The response is the ordinary task object with one
extra field:
{ "id": 42, "state": "awaiting_children", "retried_descendants": 2 }
retried_descendants is always present — 0 for an ordinary blocked retry
with nothing blocked under it — so a client never has to tell “no cascade” from
“an old daemon”. It is a count and not a list of ids: re-fetch
GET /v1/tasks?parent_id={id} if you need them. A blocked parent does both,
its own retry and then the cascade. A descendant that is awaiting_gate,
paused or itself parked is left alone; an aborted lane is not re-admitted
by anything, and is still fixed and retried by hand. A blocked lane an open
linked chat has locked is skipped too: it stays
blocked, is not counted, and is retried by hand once its chat is closed.
/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 (or
paused, when the request said paused: true), plus warnings.
A named workflow’s declared fields apply to a
follow-up exactly as they apply to a new task. The task’s fields, with the
optional fields object laid over them key by key, are checked against that
workflow: a missing required field takes its default:, and one with no
default, a mistyped value, or a value outside an enum is
400 validation_failed. A value the task was created with is checked too, since
the workflow it was legal under is not the one about to run — send fields to
supply or correct it. A declared key you send blank is dropped rather than
laid over as an empty string, so it unsets the field for this run instead of
clearing it to blank: the task’s own value does not come back, and a required
one is then 400 validation_failed. With prompt or run there is nothing to
check, and fields is only what the run renders.
Those values belong to this run. .Task.Fields in its templates, and in any
fan-out lane it spawns, carries them; the task keeps the fields it was created
with, and the next follow-up starts from those again.
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),
not_ours (kept — vincent did not cut the branch, because the task came from a
pull request or was created on an existing branch, so it never deletes it and the
remote leg does not run either), 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
queuedtask may be waiting on a clock, not a slot. Every task representation carriesqueued_reasonandadmit_not_before(RFC3339, ornull). Both arenullfor 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, andretry_backoff, a step’s failed attempt being paced by itsretry_backoff. They are separate fromblock_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 fields —
project_name,step_total,step_name,status_message, andcost_usd/input_tokens/output_tokensrolled 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 insteps[].
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": [],
"cost_usd": 12.84
}
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.
cost_usd is what the subtree has spent: every attempt of every step of every
descendant, archived ones included. It does not include the task’s own
spend, which is in its steps[], so a root’s tree total is the two added
together. That total is what
max_tree_cost_usd is compared against,
and it is how you see why a lane in blocked carries tree_cost_limit.
cost_usd is null, never 0, when no descendant reported a cost, which is
every lane that ran on codex or cursor.
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, "total": 7, "max_iterations": 10,
"item": "internal/store",
"body_step": "repair", "body_index": 2, "body_total": 3
}
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. total is what the loop is actually
running: the count:, or the length of the for_each list the running
admission derived, which the workflow does not carry because the list is
rendered at run time. Render total when it is there and fall back to
max_iterations when it is not, so a 3-item for_each under a ceiling of 10
reads loop 2/3 rather than loop 2/10. total is absent until the loop has
a body row at all — before the first iteration there is nothing to report and a
denominator would be a guess that reads like an answer. A row written before the
daemon recorded extents carries none, and total is then the same bound
max_iterations reports, which is the number that row’s rollup gave for its
whole life; the fallback happens on the daemon, so a client that renders total
whenever it is present is right either way.
body_step names the body step that iteration is on, with body_index its
1-based place among body_total body steps; the outer step k/n counts a
whole loop as one step, so this is the only thing on the response that says
where inside the loop the task is. The three are absent together, and only
together, for a row whose step_id is not one of the body ids the task’s
workflow snapshot declares: a repair row, or a row whose step an edit-and-retry
rewrite of the snapshot (§6) has taken out of the body. A position counted
against a body the row did not run in is worse than no position. (A snapshot
that no longer parses at all does not narrow the rollup, it removes it: loop
is absent from the response entirely, because nothing is left to say the current
step is a loop.) The rollup is on
the list endpoint too, so a board can render
loop 4/7 · internal/store · repair 2/3 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.trueselects only archived tasks,allreturns both.- Every task representation carries
available_actions(the actions valid right now) andpause_requested, so clients never restate the state machine. While a linked chat is open it also carriesopen_chat_id, andavailable_actionsis["cancel"]where cancel is legal (blocked,awaiting_gate) and[]otherwise.open_chat_idis omitted when no chat is open. The detail response addsworkflow_steps[]— this task’s snapshot, which is what edit-and-retry prefills an editor with, reflecting any earlier edit. A step spliced in bytype: includecarriesresolved_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.
Paused, restricted and capped tasks
Three optional POST /v1/tasks fields limit how a new task runs. Each is its
own choice, and they combine.
| Field | Type | Does |
|---|---|---|
paused |
bool | Creates the task in paused instead of queued. The scheduler never sees it until POST /v1/tasks/{id}/resume, so no agent can start between creating and holding it |
restricted |
bool | Runs every agent step permission_mode: restricted, including a step whose own field says full-auto. It only tightens: nothing makes a step looser than its workflow wrote it |
max_task_cost_usd |
number ≥ 0 | This task’s own spend cap. The task blocks cost_limit at the lower of it and config.yaml’s max_task_cost_usd; 0 or absent on either side means no cap from that side, so a task cap can tighten the global one but never lift it |
curl -sS -X POST "http://127.0.0.1:$PORT/v1/tasks" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"project_id":1,"title":"Triage the flaky test","paused":true,
"restricted":true,"max_task_cost_usd":2.5}'
- A negative
max_task_cost_usdis400 validation_failed. The cap is inert on codex and cursor, which report no cost. restrictedgoes through the same creation check a workflow’s ownrestrictedstep does: if an agent step would run on an adapter that cannot restrict on this host — cursor on Windows — the create is400 validation_failednaming the step and the agent, rather than a task that fails later.- Both limits are recorded on the task and fixed at creation. Every task
representation carries
"restricted"(bool) and"max_task_cost_usd"(the number, ornullwhen the task set none).
Chats
A chat is a titled conversation with an agent, scoped to a project, running
in its own git worktree and vincent/{id}-{slug} branch — or, when it was
opened on a task, in that task’s, or, with
existing_branch, on a branch that already exists. Each turn resumes
the agent CLI’s own session, so turn N has turns 1..N-1 in context. Chats are a
separate family from tasks: they never appear in GET /v1/tasks or on the
board, and tasks never appear here.
GET /v1/chats?project_id=&task_id=&state=&archived=&archived_before=
&archived_since=&limit=&offset=
newest first; state may repeat
POST /v1/chats create, with a worktree and a branch — or, with
existing_branch, on a branch that is already there
POST /v1/tasks/{id}/chat open a chat on a stopped task, in its worktree
GET /v1/chats/{id} { chat, turns[] } — the whole conversation
POST /v1/chats/{id}/send start a turn
POST /v1/chats/{id}/answer answer a mid-run request
POST /v1/chats/{id}/cancel stop the live turn
POST /v1/chats/{id}/archive remove the worktree; terminal
POST /v1/chats/{id}/handoff give the worktree and branch to a new task; terminal
POST /v1/chats/{id}/close end a chat opened on a task; terminal
DELETE /v1/chats/{id} permanent delete of an archived or closed chat
— see Permanent delete
GET /v1/chats/{id}/events SSE: this chat's events plus its live output
GET /v1/chats/{id}/turns/{seq}/transcript
one turn's transcript, with ?offset= / ?tail=
GET /v1/chats/{id}/skills?refresh= the skills the chat's agent would load — see Skills
GET /v1/chats/{id}/files?limit= the files of the chat's workspace — see Files
None of these is an MCP tool — the whole family is
excluded, the stream, the transcript, the skill list and the file list
included, and so is POST /v1/tasks/{id}/chat, which lives under /v1/tasks
but starts a chat.
handoff is on that list for a reason worth stating: it creates a task, and
task_create’s bounds (mcp.max_depth, mcp.max_tasks) are walked over
created_by_task_id, which a chat is not in.
archived=false|true|all is GET /v1/tasks’ parameter, spelled and defaulted
the same way: terminal chats are hidden unless you ask for them. It covers
all three terminal states — archived, handed_off and closed alike —
which its name does not say; an explicit state= wins over it, and anything but
false|true|all is a 400 validation_failed. task_id= narrows the list to
the chats opened on one task; add archived=all to include the closed ones.
Every chat carries linked_task_id when it was opened on a task, and omits it
otherwise.
POST /v1/chats/{id}/archive is legal from idle alone, so its 409 names
the state that blocked it: an already-archived or already-closed chat is told
so, and a handed-off one that the task owns its worktree now. A live chat
opened on a task is refused with chat_linked_to_task and details.task_id.
POST /v1/chats/{id}/handoff takes POST /v1/tasks’ body and is validated by
the same code, so it accepts exactly the task the create route accepts.
project_id, base_branch and branch_name are the chat’s and are ignored.
It answers 201 { "task": {...}, "chat": {...} }: the task carries
source_chat_id, and the chat comes back handed_off with handoff_task_id
set and its worktree_path cleared — the claim moved, in one transaction with
the task row, the link, both durable events (task.created and
chat.handed_off) and the transition.
Everything is validated before anything is written, so a refusal leaves the
chat exactly as it was: 400 when the task does not validate, 409 when the
chat is not idle, has no worktree to give, or its worktree is partway through a
git operation (code repo_operation_in_progress, with details.operation
naming it). Ordinary uncommitted work is preserved, never refused and never
committed. A live chat opened on a task has nothing of its own to hand over and
is refused chat_linked_to_task, naming the task.
Creating one
curl -sS -X POST http://127.0.0.1:PORT/v1/chats -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"project_id": 1, "title": "poke at the parser"}'
branch_name is accepted only with existing_branch: true, which runs the chat
on a branch that already exists under the rules
above — a chat that cuts its own branch is
named from its id, so there is nothing to name. Either field without the other
is a 400, and a branch another task or chat is already working in is a 409
carrying branch and claimed_by: a chat runs as soon as it is created and has
nowhere to wait.
agent is optional and defaults to the first registered adapter that can
resume — there is no defaults.agent key, and a chat’s whole premise is
continuity. An adapter that cannot resume is refused:
{"error": {"code": "agent_cannot_resume",
"message": "agent \"someagent\" cannot resume its own session, so it cannot hold a conversation; vincent refuses this rather than replaying the log as prompt context"}}
All three shipped adapters can resume, so today nothing hits this. It is kept
because it is the contract for the next adapter: faking continuity by replaying
the log into the prompt is exactly what the refusal exists to prevent. Ask
GET /v1/agents for supports_resume rather than assuming a
list.
States
idle → running → idle, with awaiting_input in the middle when the agent
asks something, and three terminal states: archived; handed_off for a chat
whose worktree and branch now belong to a task; and closed for a chat opened
on a task that has ended. Anything outside that table is a 409 — sending to an
archived chat, answering one that asked nothing, handing off one that has
already been handed off. There is no pause: a paused chat is an idle one nobody
has sent to.
A chat opened on a task follows a second table. From idle it offers send
and close, and nothing else: archive and hand_off would reach a worktree
and a branch that belong to the task.
A chat on a stopped task
When a task stops for a human — blocked, awaiting_gate, done or
aborted — you can open a chat on the task. It works in the task’s own
worktree and on its branch, so the agent sees the files exactly as the task left
them, and the conversation is recorded like any chat’s, with a transcript per
turn and its own cost.
curl -sS -X POST http://127.0.0.1:PORT/v1/tasks/7/chat \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title": "why does the check fail?"}'
Every field is optional, and the body may be left off. title defaults to the
task’s. agent, model and effort resolve the way
/repair’s do — what you send, then the task’s overrides, then the
workflow’s defaults:, then the adapter’s default — and an unset agent falls to
the first adapter that can resume. The chat is restricted when the task’s
workflow defaults: or the task’s own restricted say so. The answer is 201
with the chat, idle, carrying linked_task_id and an empty worktree_path:
the worktree is still the task’s, and every turn reads the task’s path when it
starts.
The task does not move, and no task.* event is sent. Its first turn’s prompt
opens with the task’s context, assembled by the daemon when the chat is opened:
the title, description and fields; for blocked, the same failure block
/repair gets, including the last 200 lines of the failed attempt’s
transcript and its path; for awaiting_gate, the gate’s text; for done and
aborted, the last step’s summary and the abort reason. Your message follows
it, as literal text. Later turns carry no context — the session has it.
While the chat is open, the task is locked. Every task action except
/cancel is 409 task_locked_by_chat with details.chat_id, whoever sends it —
a client, a trigger reaction, or an agent through the MCP task_* tools. The
task’s available_actions says so (["cancel"] or []) and it carries
open_chat_id. PATCH /v1/tasks/{id} and the pull-request routes are not
actions and still work.
Refusals when opening:
| Status | Code | When |
|---|---|---|
409 |
invalid_state |
The task is not in one of the four states; details.state says where it is |
409 |
task_locked_by_chat |
A chat is already open on it; details.chat_id names it |
409 |
task_has_no_worktree |
The task never got one — blocked on branch_exists or base_branch_missing, or aborted before it started. Vincent does not create one for a chat |
400 |
validation_failed |
An unregistered agent; or, on a task that runs restricted, an agent that cannot keep a resumed turn restricted — codex, whose exec resume has no --sandbox |
400 |
agent_cannot_resume |
The agent cannot hold a conversation |
A git operation in progress in the worktree is not a refusal: a half-finished rebase is a good thing to talk about.
Closing ends it:
curl -sS -X POST http://127.0.0.1:PORT/v1/chats/12/close -H "Authorization: Bearer $TOKEN"
A live turn is stopped first. The chat becomes closed, the task’s lock lifts,
and a chat.closed event names the task in linked_task_id. Nothing in the
worktree or on the branch changes. The answer is 200 with the chat; a free
chat, or one already terminal, is 409 invalid_state. After closing, the task
takes actions again and a new chat can be opened at its next stop; the closed
ones stay listed under GET /v1/chats?task_id=7&archived=all.
/cancel on the locked task is the other way out: it stops the turn, closes the
chat and aborts the task together.
Three more things to know:
- Archive, hand-off and
delete_branchare refused on a chat opened on a task with409 chat_linked_to_taskanddetails.task_id. A closed one can be deleted withoutdelete_branch, and permanently deleting the task deletes its closed chats and their transcripts with it. - Turns do not count toward the task’s cost cap. Their tokens and cost stay
on the chat, so neither
max_task_cost_usdnor the task’s own cost rollup sees them.max_parallel_chats,agent_timeoutandinput_timeoutapply as to any chat. - A task that runs in a container has its chat’s turns run in that container too. If the container is gone, the turn fails rather than running on your host. A turn interrupted by a daemon restart is not re-run; the chat stays open, and the task stays locked.
Skills
curl -sS http://127.0.0.1:PORT/v1/chats/3/skills -H "Authorization: Bearer $TOKEN"
The skills the chat’s agent CLI would load for its next turn, in the directory that turn runs in — the chat’s own worktree, or its task’s for a chat on a task — and the exact text a message uses to invoke each one. It answers from the moment the chat exists, before any message, and while a turn is running.
The list comes from wherever that turn would run. A chat on a task that runs
in a container is listed inside that
container, by the image’s own CLI reading the container’s home, so what you
see is what that turn will load and not what your host would. Note that
~/.agents is not mounted into a task container, so a containerized codex or
cursor chat lists no skills from it.
{ "chat_id": 3, "agent": "codex", "work_dir": "/home/me/.local/share/vincent/worktrees/3",
"list_verdict": "supported", "unavailable_reason": "",
"probe_error": null, "probed_at": "2026-09-19T10:04:00Z",
"invoke_verdict": "supported", "invoke_sigil": "$", "invoke_position": "anywhere",
"builtin_skills": "",
"skills": [ { "name": "release-notes", "invocation": "$release-notes",
"description": "Draft release notes from the changelog",
"argument_hint": "", "aliases": [], "scope": "repo", "plugin": "",
"path": "/home/me/.local/share/vincent/worktrees/3/.agents/skills/release-notes/SKILL.md",
"builtin": false } ],
"problems": [] }
list_verdict says what skills means:
supported—skillsis the list, in the CLI’s own order; a name can appear twice, because the CLIs do not merge same-name skills. An empty list means there are none. Aprobe_errorbeside it means the latest probe failed and this is the last list that succeeded, obtained atprobed_at.unsupported— the agent cannot list its skills, with the reason inunavailable_reason. Today cursor answers this.unknown— nobody can say: the probe failed with no earlier list (probe_error), the chat’sagentnames no adapter this daemon has (probe_error), or the chat’s task is configured to run in a container and that container is gone (unavailable_reason, andwork_diris then""). Treat it as unknown, never as “none”. vincent will not list a missing container’s skills from your host: the answer would name skills the agent never loads.
Type the invocation as it is; never build one from the name and the sigil,
because codex writes [$name](path) for a name two skills share.
invoke_verdict is unsupported for an agent that cannot invoke skills, and
unknown for an agent with no adapter, with
invocation, invoke_sigil and invoke_position then "". invoke_position
is leading when the invocation only works at the start of the message
(claude) and anywhere otherwise. scope, plugin, description and
argument_hint are the CLI’s own words, "" when it said nothing. problems
lists SKILL.md files the CLI found and could not load, as {path, message}.
builtin on a row marks a skill the agent CLI ships itself — claude’s
simplify, loop and run. builtin_skills says whether you are seeing
them:
listed— you are. A turn has run on this agent’s installed binary and reported the skills it loaded, so the bundled ones are inskills, flagged.after_first_turn— not yet. claude marks its bundled skills and its built-in commands (/clear,/compact) the same way, and only a turn’s own report tells them apart, so until then all of them are left out. The first turn on that CLI brings them back in every chat and every directory it runs in, not just the one that ran it — a turn inside a task’s container answers for the container’s CLI and a turn on your host for your host’s, never for each other. vincent never lists a built-in command, before or after.""— the question does not arise: there is no list, or this CLI marks nothing as its own, which is every codex and cursor chat.
The answer is cached for five minutes, and a failed probe for one.
?refresh=true asks the CLI again. Every finished turn clears the cache for
its directory, so refetch on chat.turn_changed and a skill the agent just
wrote shows up. Listing starts the agent CLI in that directory without a
prompt — on your machine, or inside the task’s container for a chat on a
containerized task. A chat that is archived, handed off or closed is
refused with 409 invalid_state (details.state, details.action:
"skills"), and a chat on a task that has lost its worktree with
409 task_has_no_worktree (details.task_id).
Files
curl -sS http://127.0.0.1:PORT/v1/chats/3/files -H "Authorization: Bearer $TOKEN"
The files of the directory the chat’s next turn would start in — the chat’s
own worktree, or its task’s for a chat on a task —
each with the exact text a message uses to mention it. It is what a composer
offers behind @, and it answers from the moment the chat exists.
{ "chat_id": 3, "agent": "claude",
"work_dir": "/home/me/.local/share/vincent/worktrees/3",
"mention_sigil": "@", "mention_position": "anywhere", "mention_expands": true,
"files": [ { "path": "internal/api/server.go", "mention": "@internal/api/server.go" },
{ "path": "dir with space/q.txt", "mention": "@\"dir with space/q.txt\"" } ],
"truncated": false }
Type each row’s mention as it is; never build one from the path and the
sigil, because claude needs the path double-quoted after the sigil when it
contains a space and the rule is the adapter’s, not yours. mention_sigil and
mention_position are the agent’s mention syntax — anywhere when the CLI
recognizes a mention as a token anywhere in the message, leading when only at
the start — and mention_expands says whether the CLI itself puts the file in
front of the model (claude) rather than leaving the model to read it with a
tool (codex, cursor). An agent that cannot mention files at all, or an agent
with no adapter on this daemon, answers "" for both strings, false for
mention_expands, and "" for every row’s mention: the paths are still
listed, and the empty sigil is the signal not to offer a picker.
What the listing contains is git ls-files --cached --others
--exclude-standard in that directory:
- Untracked files are in it. A file created a minute ago and never
git added is usually exactly the file you want to point an agent at. .gitignoreis honoured, sonode_modules/and a local.envstay out — and a committed secret is listed, because it is in the repository and the agent can already read it. See the security model.- Directories are not rows, and neither are submodule contents.
- Paths are git’s own: workspace-relative, forward-slash on every platform (Windows included), in git’s order, not sorted and not case-folded. A path that cannot travel as JSON — invalid UTF-8, or a control character — is left out.
A chat on a task that runs in a container is
listed on your host, unlike its skills. The task’s worktree is
bind-mounted into the container at its own path, so the host reads the same
directory the agent reads at the same path the agent would type. One
consequence: work_dir here is a host path, where the skills route’s is a
container path for that same chat.
The daemon serves at most 50,000 rows and sets truncated when it cut any.
?limit=N takes a positive integer and may only lower that ceiling, never
raise it; files is always an array, never null. There is no cache and no
?refresh=: the listing is one short-lived git ls-files, so fetch it again
whenever you need it fresh — when the picker opens, for instance, since a task
step writing in a linked chat’s worktree emits no chat event.
A chat that is archived, handed off or closed is refused with
409 invalid_state (details.state, details.action: "files"), before any
git runs, and a chat on a task that has lost its worktree with
409 task_has_no_worktree (details.task_id). A workspace that has been
deleted under the daemon, and a limit that is not a positive integer, are
each 400 validation_failed; any other git failure is a 500.
Sending a turn
curl -sS -X POST http://127.0.0.1:PORT/v1/chats/3/send -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"message": "what does buildArgs do with an empty model?"}'
202 with the new turn. Poll GET /v1/chats/3 or follow GET /v1/events for
chat.turn_changed. A chat has no live-output stream over HTTP: the turn’s
normalized output goes to the turn’s transcript file, and result_text on the
finished turn is the answer.
Over max_parallel_chats it is 409 chat_cap_reached, immediately — never
queued. A chat is a foreground reply, and waiting behind batch work is the thing
chats exist to avoid, so you get an error you can act on instead of a spinner.
The chat is left exactly as it was: still idle, with no turn row behind it.
Turns
Each turn carries the accounting a step run does — input_tokens,
output_tokens, cost_usd, exit_code, duration_ms — plus session_id, the
session it actually ran in, and its own transcript at
{data_dir}/transcripts/chat-{chat_id}/{seq}.jsonl. A turn is running, then
done, failed or interrupted, forever.
curl -sS "http://127.0.0.1:PORT/v1/chats/3/turns/2/transcript?offset=0" \
-H "Authorization: Bearer $TOKEN" -D -
The turn is named by its seq, not by a run id: a chat turn is its own run.
?offset= and ?tail= are mutually exclusive, the body always covers whole
records, and X-Next-Offset names where the next fetch resumes — the same
contract a step’s transcript has. Add
?format=normalized for vincent’s own vocabulary instead of the agent’s
dialect.
Following one
curl -N http://127.0.0.1:PORT/v1/chats/3/events -H "Authorization: Bearer $TOKEN"
This chat’s durable chat.* events interleaved with its live output — the
per-task stream’s shape for a chat. Another chat’s events do not
arrive on it and neither do a task’s. Last-Event-ID resumes the durable
events; live output is ephemeral and is never replayed, so a reconnect catches
up by re-fetching the running turn’s transcript and discarding every chunk whose
offset is at or before the X-Next-Offset it reported. Chunks carry
chat_id, turn_id, offset, the same normalized fields a task’s chunks carry
under the same type names (agent.output, agent.tool_use, agent.tool_result,
agent.run_header, agent.thinking, agent.plan, agent.command_output,
agent.patch, agent.subagent_started, agent.subagent_progress,
agent.subagent_finished, agent.skill, agent.conversation_reset,
agent.usage), and the agent’s
own raw line beside them.
A chat’s stream carries one type a task’s does not: agent.raw, for a line
vincent’s parsers do not model. A task leaves those to its transcript, but a
chat has no timeline of steps beside its output, so a turn whose stream is all
unmodeled lines would show nothing at all while it runs. agent.result and
agent.error are not published live on either: they reach you as the turn’s
own state and in its transcript. Nor is agent.input_echo: a turn’s echo of
your message is already on screen as the message, so it publishes no chunk,
neither as itself nor as agent.raw.
Two failure reasons are worth knowing:
session_lost— the CLI no longer knows the stored session. The turn fails, the chat stays usable and keeps its id, and nothing starts a fresh session behind your back: an agent answering with none of the conversation in context reads exactly like one that has it, so starting over is a decision you make explicitly. claude and codex both report this; cursor cannot — it adopts an unknown session id and answers — so a cursor chat is the one place that reading is unavoidable, and it is a property of the CLI rather than a choice vincent makes.interrupted— the daemon restarted under a live turn. It is not re-run, unlike a task’s step: re-running would re-send your message into a session that died with the process. The chat returns toidle.timeout/input_timeout— the turn ran pastdefaults.agent_timeout, or the chat sat inawaiting_inputpastdefaults.input_timeout. The process tree is killed, the chat returns toidleand itsmax_parallel_chatsslot is released. The two clocks are the ones a workflow step gets, with no chat-specific key and no per-turn override.
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 is404. 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_changedevent — 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?by=
The transcript is the attempt’s JSONL file, ranged:
offset=(bytes) andtail=(last N bytes) are mutually exclusive.tailopens 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.offsetis taken as given.- The body always ends on a complete line, and
X-Next-Offsetreports that boundary — never mid-record, so a follow-up fetch on a file still being appended to resumes cleanly. format=normalizedmaps every line through the owning adapter’s parser into the live-output shapes plusagent.result,agent.error,agent.input_echo,agent.conversation_reset, thevincent.*kinds, andagent.rawfor anything unrecognized. That is one render path for live tail and scrollback alike. Absent, you get the raw file byte for byte.agent.run_headercarrieswork_dirandavailable_tools— what the CLI announced about the run before starting it.agent.resultadditionally carriesduration_ms,api_duration_ms,num_turns,stop_reason,terminal_reason,cache_read_tokens,cache_write_tokens,model_usageandpermission_denialswhere the adapter reports them;agent.tool_resultentries carryverbandblocked; and any record may carryparent_call_id, the tool call a subagent’s line belongs to. Every one of those keys is omitted when unreported, so absent and zero are distinguishable — only Claude Code fills all of them today. Cursor fillswork_dir(neveravailable_tools),duration_ms,api_duration_ms,cache_read_tokensandcache_write_tokens, and Codex the two cache counts.-
A subagent’s lifecycle, as the main loop reported it, is three records, each keyed by
call_id, the spawning tool call every one of the subagent’s own records names as itsparent_call_id:Type Fields agent.subagent_startedcall_id,description,subagent_type,backgroundagent.subagent_progresscall_id,description,subagent_type,tool_uses,total_tokens,duration_ms,last_toolagent.subagent_finishedcall_id,status,summary,tool_uses,total_tokens,duration_msstatusis the CLI’s own word, passed through (completedandfailedare the ones seen so far).summaryis the subagent’s report cut to one line; the report itself is only in the raw transcript. These three records carry noparent_call_idthemselves: they are the main agent’s lines about a subagent. The call that spawned a background subagent reports anagent.tool_resultentry withverb: "started in background"and nosummary. Every key is omitted when unreported, and only Claude Code produces any of it. The output pane draws a record carryingparent_call_idnested, behind a rail. A claude transcript range fetched withtail=oroffset=that starts after every earlier line of a failed subagent can return that subagent’s finish asagent.raw: a failed finish line does not say it is a subagent’s, so it is recognized from the lines before it. agent.plancarriesitems([{text, completed}]) andplan_call_id— the agent’s running to-do list, whole on every record rather than as a delta, so a client that joins mid-run learns where the agent is.agent.command_outputcarriesoutput,truncated,call_idandname— what a command printed, whichagent.tool_resultdeliberately never carries, capped with the cut reported rather than silent.agent.resultadditionally carriesreasoning_tokens. Only Codex fills these today, and the same omitted-when-unreported rule applies.agent.patchcarriespatch,truncated,call_idandname(andparent_call_idwhen set) — what a file edit changed, as unified hunks each under its@@ -a,b +c,d @@header, capped with the cut reported. It is the edit’s body; the edit’s outcome is itsagent.tool_resultentry, whosesummaryis the line delta+N −M(and whoseverbisupdatedfor aWritethat overwrote a file). Only Claude Code fills it, and not for a subagent’s edits, whose results carry no patch.-
agent.skillreports that a skill’s content entered the conversation:Field Meaning namethe skill as the CLI resolved it, namespace included argsthe arguments it was invoked with, on one line byagentfor one the model loaded,humanfor one your message invokedcall_idthe Skilltool call an agent’s load came from — the samecall_idas itsagent.tool_useandagent.tool_resultforkedtruefor a skill that ran as its own sub-run (Claude Code’scontext: fork); absent otherwiseerrorthe CLI’s own refusal, when it reported one on a line of its own — to load the skill, or of a command the skill ran Every key is omitted when unreported, and the skill’s rendered
SKILL.mdis never on the record — it is in the raw transcript. Only Claude Code produces it: a skill the model loads (by: "agent", withcall_idandargs), and a skill your own message invoked (by: "human", with the arguments you typed — orforked: trueand noargsfor one that ran as its own sub-run). Only a chat turn reports the first of those two: asking claude to report what it expanded from your message is a chat turn’s argv alone, never a task step’s. A claude range fetched withtail=oroffset=that starts after theSkillcall returns that load withoutargs. ASkillcall Claude Code refused yields noagent.skill; itsagent.tool_resultreports the refusal. Codex and Cursor report no skill loads, even when a skill ran. agent.input_echois a line on which the agent CLI echoed back the prompt vincent wrote to it — Cursor does, once per run, and Claude Code on every chat turn, where the echo also covers your answers to its own questions and a/nameit could not resolve. It carries onlytype(andparent_call_idwhen set) and has no live chunk; the echoed text is in the raw transcript. It is notagent.raw, so it is not counted as unrecognized.agent.conversation_resetmarks the point at which the agent CLI threw away its own conversation and started a new one — what/cleardoes when your chat message passes it through. The turns before it stay in the chat and stay on screen, but the agent no longer sees them, and this record is what says where that happened. It carries onlytype(andparent_call_idwhen set): the ids the CLI’s own line holds are in the raw transcript, and the session vincent resumes next turn is still the last one the stream stamped. Unlikeagent.input_echoit does stream as a live chunk, with an empty payload. Only Claude Code produces it; Codex and Cursor have no comparable line and vincent never infers one from a session id that changed.- One stream line can produce two records: codex reports a command’s outcome and the body it printed on a single event, and claude an edit’s outcome and its hunks, and they are separate records because clients show them at different verbosity levels. Read the NDJSON as a record stream, not one record per source line.
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
commit the task was cut from — its recorded base_sha, and the base branch by
name for a task that has none — including uncommitted changes. Untracked files
are excluded — a documented limitation.
?by=lane asks for the same change split by the fan-out lane that produced
it, as JSON rather than as text/plain:
{
"sections": [
{"lane_id": "api", "child_task_id": 42, "merge_commit": "a1b2c3d", "remainder": false, "diff": "diff --git …"},
{"lane_id": "docs", "child_task_id": 43, "merge_commit": "e4f5a6b", "remainder": false, "diff": "diff --git …"},
{"lane_id": "", "child_task_id": 0, "merge_commit": "", "remainder": true, "diff": "diff --git …"}
]
}
One section per lane, in the order the parent merged them, cut from the
Merge lane '{id}' of task {n} commits on the parent’s own first-parent chain —
which is why that message is a contract and not a convenience. The trailing
remainder section is everything that belongs to no lane: the parent’s own
commits and its uncommitted work. Every key is always present — lane_id,
child_task_id and merge_commit are zero on the remainder — and a lane that
merged cleanly without changing anything is a section with an empty diff
rather than a missing one.
A task that never fanned anything out comes back as a single remainder
holding the whole diff, so a client can read the grouped shape unconditionally.
Without the parameter the response is byte-for-byte the text/plain body it has
always been, and any other value is a 400 validation_failed rather than a
silent fall-through.
Events (SSE)
GET /v1/events?types=&project_id=
GET /v1/tasks/{id}/events
GET /v1/chats/{id}/events
Two kinds of stream, with deliberately different guarantees. The per-chat one is the per-task one’s twin, over a chat instead of a task, and is documented there.
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
task.github_pull_changed
chat.created chat.state_changed chat.turn_changed
chat.archived chat.handed_off chat.closed
task.deleted chat.deleted
task.restored
project.* workflow.registry_changed
trigger.fired trigger.poll_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-IDstarts live at the next committed event. The stream never replays history unasked: catch-up is a REST snapshot first, then the stream. task.deletedandchat.deletedcarry{ id, title }and announce a permanent delete. They exist even though there is notask.archivedtype, and for the reason there is not one: an archive istask.state_changedwithto: archived, but a delete has no state to change to, so without a type no other client would ever learn the row is gone. The event outlives the row it records.task.restoredcarries{ id, title }and announces a task imported from a backup. Unliketask.deletedit carries the task id, so it also reaches the per-task stream. It is not a state change and triggers no notification.- There is no separate
task.archivedortask.awaiting_inputtype. Both aretask.state_changedwith the appropriateto; theawaiting_inputpayload additionally carries the request kind and a one-line summary, which is the alert clients key off. The full request comes fromGET /v1/tasks/{id}. task.step_advancedcarries{ current_step }when the engine moves the cursor without a state change, so a board’sk/ntracks a run instead of freezing.task.children_changedcarries{ task_id, child_id, to_state }and is emitted on every fan-out ancestor when a descendant is created or transitions — re-fetch thechildrenrollup when you see one. It exists because the per-task stream filters ontask_idalone, so a root’s stream would otherwise never see a depth-2 transition.task.status_changedcarries{ 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 throughLast-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_changedcarries{ agent, spent, resets_at, source }and notask_id: the fact is about an adapter, not about any one task. It is emitted when ausage_limitstop is observed, when a successful run retires an observation, and when a reported reading changes — a probe or a push saying something new. Never on a re-observation or a re-reading identical to what is already held, and never merely because a window lapsed: a status line re-renders on every prompt and pushes the same numbers many times a minute, and wakes nobody. On a reading,spentandresets_atcome from the tightest window, andresets_atisnullwhen that window named none. Re-fetchquotafrom/v1/agentsor/v1/infowhen you see one.- The
chat.*events belong to the chat family and carry the chat’sproject_id, so?project_id=filters them the way it filters a task’s. Each payload is{ id, title, state };chat.turn_changedaddsturn_id,turn_seq,turn_stateand — when the turn failed —fail_reason, andchat.handed_offaddshandoff_task_id, so a follower can link the chat to its task without a fetch. Every event of a chat opened on a task addslinked_task_id, andchat.closedannounces that such a chat ended — re-fetch that task too, because its lock just lifted. Opening or closing one emits notask.*event: the task’s state did not change. Re-fetchGET /v1/chats/{id}when you see one. There is no per-chat event stream and no live-output route: a chat’s normalized output is written to the turn’s transcript file, and over HTTP a finished turn’sresult_textis what you read. task.github_pull_changedcarries{ repo, number, source, suppressed }— empty when the link was cleared — and says a task’s pull-request link changed: the daemon’s reconciler matched one, or a human linked or unlinked one. It is not a transition: the task’s state is unchanged and itsupdated_atis untouched, because the link is a fact about GitHub rather than about the task’s own progress. Re-fetch/v1/tasks/{id}/github/pullwhen you see one.trigger.firedcarries{ trigger_id, delivery_id, action }, whereactionis the action type. The event’sproject_idis the trigger’s project and itstask_idis the task created or acted on, so?project_id=filters it and that task’s own stream receives it. It is published after the ledger row is recorded, andfiredis the only outcome that publishes. The others are ledger rows you read from/v1/triggers/{id}/deliveries: an event per filtered poll would fill the table with nothing anyone reacts to. A task a trigger created still announces itself with its owntask.created.trigger.poll_changedcarries{ trigger_id, ok, error }and notask_idorproject_id, so a?project_id=subscription never sees it. It is emitted on a trigger’s first poll after arming and on every change from ok to failing or back — never once per poll. To keep last-poll times current, re-readGET /v1/triggerson your own timer.
Live output — ephemeral
agent.output, agent.tool_use, agent.tool_result, agent.thinking,
agent.run_header, agent.plan, agent.command_output, agent.patch,
agent.subagent_started, agent.subagent_progress, agent.subagent_finished,
agent.skill, agent.conversation_reset, 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. agent.input_echo, like agent.result
and agent.error, is a transcript record that never streams: its text is
already on screen.
Each chunk is one SSE event, flushed on a ~100 ms coalescing timer, and carries:
run_id— the step-run row that produced it, andoffset— 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.
MCP
The daemon also speaks the Model Context Protocol at POST /mcp, on this
same listener behind this same bearer token. It exists so an AI coding agent can
drive vincent with tool discovery, argument schemas and typed errors instead of
hand-rolled curl. Full guide: Driving vincent from an agent.
Every route on this page is a tool, with these exceptions:
| Not a tool | Why |
|---|---|
POST /v1/daemon/stop |
An agent must not stop the daemon supervising it |
POST /v1/daemon/backup |
Destructive admin |
DELETE /v1/projects/{id} |
Destructive admin |
DELETE /v1/tasks/{id} |
Destructive admin, on the same line: a row a human archived is history nobody else may discard. Archive stays a tool — the row and its transcripts survive it |
DELETE /v1/chats/{id} |
Same |
POST /v1/tasks/import |
Destructive admin, beside the deletes it undoes: it reads a file the caller names and writes rows no agent should be able to create |
POST /v1/maintenance/gc |
Destructive admin |
POST /v1/doctor/fix |
Destructive admin |
PATCH /v1/config |
An agent must not reconfigure the daemon supervising it — a patch changes the argv it spawns, what its children inherit, and whether steps get MCP at all |
POST /v1/workflows |
Same line: a workflow file is what that daemon runs. GET /v1/workflows/schema is an ordinary tool |
PATCH /v1/workflows |
Same |
POST /v1/triggers |
An agent must not author or arm a trigger that starts agents, and enabling one is a PATCH. The reads, POST /v1/triggers/validate and both dry runs are ordinary tools: a dry run fires nothing, and /poll runs only a command the user configured |
PATCH /v1/triggers/{id} |
Same |
DELETE /v1/triggers/{id} |
Same |
POST /v1/triggers/{id}/events |
An agent that can inject events can start agents. The signature it would have to forge is no reason to offer the route |
POST /v1/tasks/{id}/github/pull/create |
A write to a forge. Nothing gates it behind the human asking for it — no config key, no confirmation the daemon can check — so an agent-callable version would be consent nobody gave. An agent that wants a pull request runs git push and gh pr create in its own worktree |
POST /v1/tasks/{id}/github/pull/merge |
Same, and because mcp.wire_steps is on by default, a tool would put the write on the step path, which nothing reaches. An agent runs gh pr merge in its own worktree |
POST /v1/tasks/{id}/github/pull/close |
Same |
POST /v1/tasks/{id}/github/pull/reopen |
Same |
POST /v1/tasks/{id}/github/pull/comment |
Same |
POST /v1/tasks/{id}/github/pull/checks/rerun |
Same |
POST /v1/agents/{name}/quota |
An agent must not forge a daemon-level fact about the host it runs on: a step reporting its own adapter at 99% would paint every board with a wall that does not exist, and a reading carries a source, not a caller |
GET /v1/events |
A tool call is request/response; use task_wait |
GET /v1/tasks/{id}/events |
Same |
every /v1/chats route |
Two reasons, either sufficient: a chat turn starts an agent CLI without going through admission, so a tool that could send one would let an agent start unqueued agent processes — the exact thing mcp.max_tasks bounds; and the recursion bounds walk created_by_task_id, a chain a chat is not in, so exposing chats would mean inventing depth semantics for a non-task. An agent that needs a conversation already has its own session |
POST /v1/tasks/{id}/chat |
It opens a chat, so the /v1/chats reasons apply although the path is under /v1/tasks. The lock it places does reach the tools: a task_* action on a locked task gets 409 task_locked_by_chat like any other client |
task_create additionally takes an optional idempotency_key string, which
becomes the Idempotency-Key header — a tool call has no header surface, and
replay protection exists for exactly the client an agent is.
A tool call is dispatched by replaying its arguments against the very handler
this page documents, so the request bounds, the validation and the error
envelopes above are the same ones a tool sees — a 409 reaches an MCP client
still carrying details.state. One tool result is capped at 256 KiB; use a
route’s own offset/limit query parameters to page.
config_get is the one tool whose body differs from its route’s: it masks
environment.set’s values and notify.command’s argv, keeping the names,
because a tool result lands in the model’s context and in the step’s transcript.
GET /v1/config itself serves them.
task_wait is the one tool with no route behind it: it blocks until a task
reaches done, aborted, archived, awaiting_input, blocked or
awaiting_gate, or until its timeout (default 5 minutes, hard ceiling 30).
POST /mcp shared endpoint; Authorization: Bearer {token}
POST /mcp/step/{run_id} per-step endpoint; the daemon wires this to its own agent steps
The per-step endpoint carries a secret minted for one step run. It is not a security boundary — see the security model.
See also
- Driving vincent from an agent — the MCP surface.
- Scripting vincent — worked examples.
- Task lifecycle — what the action endpoints do.
- Spec §13 — the normative definition.