{% raw %} # 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](#transport-and-auth) - [Errors](#errors) - [Request bodies](#request-bodies) - [Daemon](#daemon) - [Doctor](#doctor) - [Projects](#projects) - [Workflows](#workflows) - [Triggers](#triggers) - [Tasks](#tasks) - [Chats](#chats) - [Transcripts and diffs](#transcripts-and-diffs) - [Events (SSE)](#events-sse) - [MCP](#mcp) --- ## Transport and auth - HTTP/1.1 + JSON on `127.0.0.1` only. **No TLS** — it is loopback. - Every request needs `Authorization: Bearer {token}`, where the token is the contents of `{data_dir}/token` (created `0600`). This is what keeps other local users and drive-by browser requests out; CORS is additionally disabled. - **Discovery:** read `{data_dir}/daemon.json` for the port, then `GET /v1/health`. - **Versioning:** path-prefixed (`/v1`), additive changes only within a version. - **`Idempotency-Key`** (optional, `POST /v1/tasks` and `POST /v1/issues` only) makes a create replayable: if the daemon committed the task or issue but you never saw the response, re-sending the *same body* under the *same key* returns it instead of making a second one. Up to 255 bytes of printable ASCII; anything else is `400 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 — see [`POST /v1/tasks`](#tasks) for what a replay returns. ```sh 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 ```json { "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](#pushing-an-event). Branch on `details`, not on a per-case code. The exceptions are the chat conflicts and the issue main-worktree 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](#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](#a-chat-on-a-stopped-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](#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` | | `issue_worktree_moved` | A `follow_up` or chat on an issue's main task that finished and handed the issue's worktree to a later main task. Continue in that task, or start a new main task | `holder_task_id`, `state` | | `issue_has_live_main_task` | An issue delete was refused because one of the issue's main or side tasks has not finished. Finish or cancel it, then delete again | `task_id` | ## 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](#usage-quota) | | `POST` | `/v1/agents/{name}/quota` | Pushes a usage reading for one adapter → `204`. What [`vincent statusline`](cli.md#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](#doctor) | | `POST` | `/v1/doctor/fix` | Removes orphaned worktrees and compacts the database | | `GET` | `/v1/update` | The daemon's **cached** release check — see [Update check](#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](#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: ```json { "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](cli.md#vincent-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: ```json { "path": "/home/you/vincent-2026-08-25.tar.gz", "bytes": 1503238553, "database_bytes": 8601600, "transcript_bytes": 1494360064, "schema_version": 13, "created_at": "2026-08-25T14:05:00.000000000Z" } ``` - `path` must be **absolute** and must not exist. The daemon resolves it against its own working directory, not the caller's, and it never overwrites a file that is by construction somebody's backup. A path inside `{data_dir}/transcripts` is refused too — the archive would read itself. Every one of these is a `400 validation_failed`. - The **daemon** assembles the whole archive: the database copy *and* the two directory trees. That keeps exactly one process walking daemon-owned state, and it is why there is no cold-copy mode — only the daemon opens SQLite. - The database copy is `VACUUM INTO`, which runs in a read transaction, so a backup may be taken **while tasks are running**. It costs the store's single connection for the duration of the copy: other queries queue behind it, bounded by the size of the database. - The archive layout, what it excludes, and the restore rules are in [Files](files.md#backup-and-restore). There is no restore endpoint — `vincent daemon restore` runs client-side, because the daemon it would overwrite has to be down. - The daemon can also take this archive **on a schedule**, set by [`backup`](configuration.md#backup) in `config.yaml`. There is no endpoint for it: `PATCH /v1/config` turns it on, and the timer's state is the `backup` group of [`GET /v1/doctor`](#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. ```json "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 } ] } ``` - `source` is `observed`, `codex_app_server` (codex answers `account/rateLimits/read` over `codex app-server --stdio`) or `claude_status_line` (Claude Code pushes through [`vincent statusline`](cli.md#vincent-statusline)). **cursor** has no quota surface at all and reports nothing, which is unchanged. - `windows[]` is every window the source named — always an array, never `null`, 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_at` and `resets_at_reported` carry the **tightest** window of a reading: the highest percentage, ties broken by the earliest reset. They stay `null`/empty for an observation, which measures nothing. A client written before `windows[]` existed keeps working and gets the number that matters. - `spent` is derived per request, and how depends on the source. An observation is spent while `now < resets_at`; a window that has reset does **not** delete it, so `spent: false` with the timestamps intact is how "this adapter ran out at 14:05 and has since recovered" is said. A reading is spent at `used_percent >= 100` — its reset is always in the future, so the observation's rule would light a badge permanently. - `resets_at_reported` separates a fact from an estimate. `true` means the source named the reset; `false` means [`usage_limit_recheck_interval`](configuration.md) supplied it, or that a reported window named no reset at all — in which case `resets_at` is 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`](#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_error` keeps 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`](#events-sse) event. `POST /v1/agents/{name}/quota` is how a reading that cannot be fetched arrives instead: ```json { "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`](configuration.md#update) governs: ```json { "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`](cli.md#vincent-update) queries the release feed itself instead, which is also why it works before the first poll and with no daemon running. - `checked_at` is `null` and `latest_version` empty 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_available` is the daemon's verdict, computed server-side, so every client agrees. A `dev` build is never reported as behind. - `current_version` is the **daemon's** build. The binary that made the request may be a newer one, which is exactly what `vincent daemon status` reports after an update. - `error` carries 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`](cli.md#vincent-doctor) renders: ```json { "generated_at": "2026-08-15T10:00:00Z", "paths": { "config_dir": "…", "data_dir": "…", "config_file": "…", "config_file_exists": true, "config_parses": true, "config_permissions": [], "keymap_warnings": [], "config_warnings": [] }, "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 exit `1` (config that does not parse, an unresponsive daemon, a failed `integrity_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" }`, where `remediation` is the exact `chmod`. It never reaches `problems[]` and never changes the CLI's exit code: the daemon re-tightens both paths on every start, so an entry means no daemon has started on this config or something widened it since. Always empty on Windows, where modes carry no access control. - **`paths.keymap_warnings[]` is a warning, not a verdict** (task 128): one string per thing the lenient load of `tui.keys` let through — a retired operation id ignored, an operation's default left unbound by a user binding, a fixed key shadowed. It never reaches `problems[]`. - **`paths.config_warnings[]` is a warning, not a verdict** (task 132.9): one string per setting that loads and takes effect but cannot do what it asks — a deprecated `tui.board.group_by` level stripped on load, a half-written `notify` block, an unreachable `delete_remote_branch_on_archive`. The same lines the daemon logs as `config warning`. It never reaches `problems[]`. - **`tasks.unreconciled[]`** is the §12.4 contradiction: a task holding a step run still marked `running` while sitting in a state that cannot be executing one — `queued`, `done`, `aborted` or `archived`. Each entry carries `task_id`, `state` and `open_step_runs`. Such a task is refused by admission and will not run until crash recovery reconciles it, so it also raises a `tasks` problem. The waiting states are deliberately absent: an open run is correct under `awaiting_input` and `awaiting_gate`. `fan_out` rows 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 is `queued` between 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 cached `logged_in: false` would survive the user logging in — which would break the endpoint in the loop it exists for. Pass **`?probe=false`** to be served from the same cache `/v1/agents` uses instead: it is for a caller that is not in that loop and wants the rest of the report cheaply — the TUI's daemon panel, which opens on a keypress, is the one in the tree. `vincent doctor` always forces. - **`skills[]` is one row per published skill**, never one per agent. The `skills` CLI 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_version` is absent for a copy that carries no `metadata.version` — every copy installed before that marker existed — and such a copy is `older`, not `unreadable`. `state` is one of `absent`, `current`, `older`, `newer`, `differs` (unequal and at least one side is not semver, so no direction is claimed) and `unreadable`. `links[]` is what is on disk and can name agents vincent does not drive. Nothing here ever reaches `problems[]` — see [`vincent skills`](cli.md#vincent-skills). - **The database group measures growth** and changes nothing about it. `total_bytes` is the file plus its WAL and SHM sidecars, which is the honest figure: the store runs in WAL mode, so `size_bytes` alone understates the footprint between checkpoints, and a missing sidecar counts as zero. `table_rows` is enumerated from the schema itself rather than from a fixed list, so its key set describes the database this binary is talking to and a later migration's table appears with no client change. `oldest_event_at` is `null` on an install that has not recorded an event yet. `workflow_snapshot_bytes` totals the per-task workflow YAML — the second growth driver beside `events`, reported separately because one byte total cannot tell "many small events" from "a few enormous snapshots". Nothing here prunes, warns, or moves the exit code. - **`backup` is the scheduled-backup timer's state** (see [`backup`](configuration.md#backup)). `enabled`, `dir` (resolved, so `""` reads as `{data_dir}/backups`), `interval` and `keep` are the settings in effect. `last_success_at` comes from the newest scheduled archive's name, so it survives a restart. `last_attempt_at`, `next_due_at`, `last_bytes` and `retained` (how many scheduled archives are in `dir`) describe the timer's latest check. Timestamps are `null` when there is nothing to report. `last_error` and `prune_error` are omitted when empty. A `last_error` while `enabled` is `true` puts a `backup` entry in `problems[]`, with the message `the last scheduled backup failed: `, until an attempt succeeds. A `prune_error`, an old archive that could not be deleted after a successful run, never does, and neither does a `next_due_at` in the past. Reading this group never takes a backup. - `known: false` on `database`, `tasks` or `backup` means the report was composed without a daemon (the CLI's degraded path); over this endpoint they are always `true`. A `backup` group with `known: false` still carries its settings, read from `config.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/orphans` returns, from the same scan. Each carries `kind` (`worktree` or `transcript`), `task_id`, `size_bytes`, and `skip` when gc would leave it alone (`worktree_dirty`, `dirty_unknown`, `not_a_directory`). `orphans_known` is `false` only when the daemon has no reclaimer wired. `POST /v1/doctor/fix` takes `{ "force": true }` or `?force` and answers with what it did plus a report taken afterwards: ```json { "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: ```json { "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 the `daemon 5/8 · 2 lanes · 1 on input` end of its board header from exactly these three fields, after the selected project's `slots_used` and cap from `GET /v1/projects`. 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: ```json { "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: ```json { "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: ```json { "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`](cli.md#vincent-gc) for the command over these endpoints. ## Projects | Method | Path | Body / notes | |---|---|---| | `GET` | `/v1/projects` | List — `?stats=true` adds per-project counts | | `POST` | `/v1/projects` | `{ path, name?, default_branch?, default_workflow?, max_parallel_tasks? }` | | `GET` | `/v1/projects/{id}` | `?stats=true` adds its counts | | `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=` | | `GET` | `/v1/projects/{id}/github/pulls` | Its pull requests, newest first — `?state=`, `?limit=`, `?workflow=` | | `GET` | `/v1/projects/{id}/issues/sync` | How its GitHub issue import is going | | `POST` | `/v1/projects/{id}/issues/sync` | Sync now — `202` with the same body | `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`](configuration.md#delete_empty_branch_on_archive) 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. `?stats=true` on the list or on one project adds a `stats` object to each row, so a project picker can rank and badge projects without listing every task, issue and chat: ```json "stats": { "tasks": { "by_state": { "queued": 1, "running": 2, "blocked": 1 }, "active": 4, "attention": 1 }, "issues": { "open": 37, "open_imported": 30, "active": 3, "lanes": { "open": 30, "in_progress": 3, "hand_off": 4, "done": 112 } }, "chats": { "live": 2, "awaiting_input": 1 }, "issue_sync": { "enabled": true, "ok": true, "reason": "", "last_synced_at": "2026-10-04T09:12:00Z" }, "last_activity_at": "2026-10-04T09:40:13Z" } ``` | Field | Counts | |---|---| | `tasks.by_state` | Non-archived tasks per state, fan-out lanes included. States with no task are omitted, and `archived` never appears | | `tasks.active` | Tasks not yet `done` or `aborted`, fan-out lanes included — counted over the same rows as `slots_used` (every task, not only roots), so the two agree about lanes. It is not `slots_used`: a `queued` or `blocked` task is active without holding a slot. This is **not** an issue's `active`, which counts only root tasks | | `tasks.attention` | Tasks in `awaiting_input`, `awaiting_gate` or `blocked`, lanes included. A fan-out parent in `awaiting_children` is not counted; its lane that needs you is | | `issues.open` | Open issues | | `issues.open_imported` | Open issues imported from GitHub | | `issues.active` | Open issues with an unfinished root task, as on the issue itself | | `issues.lanes` | Issues per [lane](#issues) — `open`, `in_progress`, `hand_off` and `done`, every key always present. Closed issues are counted, as `done`, which the three figures above never count | | `chats.live` | Chats in `idle`, `running` or `awaiting_input` | | `chats.awaiting_input` | Chats waiting on an answer. It is kept apart from `tasks.attention` | | `issue_sync` | The stored import health: the `enabled`, `ok`, `reason` and `last_synced_at` of [`GET /v1/projects/{id}/issues/sync`](#github-issues), read without asking git for the repository | | `last_activity_at` | The newest change to any of the project's tasks, issues or chats; `null` when it has none | Without `stats`, or with `stats=false`, the response is exactly the default shape and has no `stats` key. Any other value is `400 validation_failed`. The counts cost the daemon a fixed handful of queries however many projects are registered. If they cannot be read, each row carries `"stats": null` and the request still succeeds. `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: ```json { "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: ```json { "enabled": true, "repo": "lezli01/vincent", "available": true, "via": "gh" } ``` `enabled` is [`github.enabled`](configuration.md#github). `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) | | `gone` | GitHub answered 410: the issue was deleted | | `moved` | GitHub answered 301: the issue was transferred, or its repository renamed. vincent never follows the redirect | Those reasons are the whole client-facing vocabulary. `gone` and `moved` come from the issue sync's per-issue reads, which record them on the issue as `source.status` `missing` and `moved` rather than on any route; everything else 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](#acting-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. Any other parameter is ignored. ```json [ { "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" } ] ``` The listing is a remote browse and nothing more. It carries no prefill, and a GitHub issue reaches a task only once the project has imported it: create the task from the imported issue with [`issue_id`](#creating-a-task-from-an-issue) on `POST /v1/tasks`, finding its id with [`GET /v1/issues?project_id=P&remote_number=N`](#issues); `GET /v1/issues/{id}?workflow=` previews the prefill that create applies. The `?workflow=` parameter this listing used to take, and `github_issue` on `POST /v1/tasks`, were removed (tasks 130.11 and 130.13); the parameter is ignored now, like any unknown one. ### Issue sync On every [`github.poll_interval`](configuration.md#github) tick the daemon imports and refreshes each GitHub-based project's issues into its [backlog](#issues). `GET /v1/projects/{id}/issues/sync` reads how that is going; it never calls GitHub, and never starts a sync: ```json { "enabled": true, "provider": "github", "repo": "lezli01/vincent", "last_synced_at": "2026-10-02T09:15:00Z", "ok": false, "reason": "rate_limited", "import_complete": true, "rate_limited_until": "2026-10-02T10:00:00Z", "writes_pending": 1, "writes_failed": 0, "writes_conflict": 0 } ``` `enabled` is `github.enabled` and a non-zero `github.poll_interval` together. `repo` is the repository the project's first successful sync bound it to, or the one its `origin` names until then. `last_synced_at` is the last successful sync and is absent when none has been. `import_complete` turns `true` once the first import of the open issues has finished; it runs at most 500 issues a tick and resumes where it stopped. `rate_limited_until`, present only while backing off, is GitHub's reset time: no call is made, sync-now included, until then. When `ok` is false, `reason` says why: | `reason` | Meaning | |---|---| | `github_disabled` | `github.enabled` is false | | `poll_disabled` | `github.poll_interval` is `0`: the integration is on, the background tick is not | | `pending` | The importer has not attempted this project yet | | `not_github` | No `origin`, or one that is not a github.com repository | | `no_client` | The daemon has no GitHub client wired | | `origin_changed` | `origin` now names a different repository than the one the project is bound to. Sync stops and is never re-keyed; a rename GitHub redirects is not this | | any [GitHub reason](#github-issues) | The last attempt failed with it — `rate_limited`, `no_credential`, `unauthorized`, `unreachable`, … | Tasks created from a GitHub issue before the issue set existed (task 035's `github_issue` snapshots) were backfilled into it when the daemon was upgraded: one imported issue per repository and number, linked to every such task, its title, body, labels and state taken from the newest snapshot. Those issues have never been synced. The first sync that sees each one on GitHub adopts it, refreshes its content and takes GitHub's state, and writes nothing back. A backfilled issue whose repository is not the one the project is bound to — `origin` was re-pointed since the task was created — is never matched by a sync, and stays as it was backfilled. The two config switches take precedence over whatever the importer last recorded. None of these is a task's `block_reason`: a sync is not a task, and nothing about it blocks one. `POST /v1/projects/{id}/issues/sync` is sync now. It records the request, wakes the importer and answers **`202`** at once with the status as it stands, not the outcome of this sync, which lands on the importer's goroutine and announces itself with [`issue.sync_changed`](#state-events--durable) when it flips the verdict. With a switch off the request is recorded, `reason` names the switch, and nothing is imported until it is turned back on. It also wakes the [state write-back](#state-write-back). It is not an [MCP](#mcp) tool. An unknown project is `404` on both. #### State write-back A person's close or reopen of an issue imported from GitHub is written back to GitHub. The write is recorded in the same transaction as the change, so it survives a crash, and the daemon sends it at once: it reads the GitHub issue first, sends the write only if GitHub still holds what vincent last saw, and sends nothing if GitHub already holds the new state. If someone changed the issue on GitHub in between, GitHub wins: its state is adopted here and the write ends `conflict`. Writes are at least a second apart and wait out a rate limit. Every issue imported from GitHub carries the outcome: ```json "sync": { "state": "failed", "reason": "no_write_scope", "last_synced_at": "2026-10-03T09:15:00Z" } ``` | `state` | Meaning | |---|---| | `synced` | The issue agrees with GitHub: its last write landed, or it never had one | | `pending` | A write is waiting. `reason`, when present, is why it has not gone yet — `disabled` (`github.enabled` is false or `github.poll_interval` is `0`; nothing is called, and it goes when the switch is back on), or a [GitHub reason](#github-issues) it is retrying after: `rate_limited`, `unreachable`, `timeout`, `bad_response`, `no_credential`, `unauthorized` | | `failed` | The write gave up; the local state is kept. `reason` is `no_write_scope`, `not_found`, `gone`, `moved`, `forbidden` or `bad_request` — or, without a write, `moved`/`gone` for an issue whose GitHub copy was transferred or deleted, whose state changes stay local | | `conflict` | GitHub's state changed first and was adopted here (`reason: remote_changed`) | `last_synced_at` is the later of the last import refresh and the last write that landed. A write's outcome is announced as [`issue.updated`](#state-events--durable) with `changed: ["sync"]`, and the project's counts are `writes_pending`, `writes_failed` and `writes_conflict` above. Only a person's change is written back: an agent's — over MCP, or a `vincent` command run by a workflow step or a chat agent, which send `X-Vincent-Task-Id` / `X-Vincent-Chat-Id` — is refused with `forge_write_needs_human`. That marker is best-effort, not a security boundary: see the [security model](../security-model.md). ### 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 — `{ title, description, fields }`, 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). ```json [ { "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`](#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 | ```json { "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**: ```json { "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 `GET`s 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 was created from a GitHub issue in the same repository — an [imported issue](#creating-a-task-from-an-issue) or a legacy `github_issue` snapshot. A local issue adds nothing. 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](#acting-on-a-linked-pull-request). It pushes the task's branch to `origin` and creates its pull request: ```sh 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`](configuration.md#github) 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](#mcp) — 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: ```json { "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](#github-issues), 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: ```json { "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`](configuration.md#github) 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_pull`, `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: ```json { "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 }` | ```sh 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](#github-pull-requests) 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](#github-issues) — a 403 is `no_write_scope` — and never GitHub's own text: ```json { "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](#mcp). ## 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](workflow-schema.md#fields). 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](workflow-schema.md#platforms) 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`](workflow-schema.md#type-agent) 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`](workflow-schema.md#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](#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 ``` ```json { "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). ```json { "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](workflow-schema.md#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](workflow-schema.md#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: ```json { "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 `` 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. ```json { "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`](#daemon) 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`](#daemon) 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](workflow-schema.md) 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`](#daemon). ## 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](#dry-runs). 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](#the-delivery-ledger), newest first → `{ deliveries[] }` | | `POST` | `/v1/triggers/{id}/events` | The `type: http` ingress: one signed event → its delivery. See [Pushing an event](#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`](configuration.md#triggers) is on. That key is switched over [`PATCH /v1/config`](#daemon). 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: ```json { "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` and `on_fire` are absent on such a row, because there is no parsed definition to read them from. `project_id` is still read from `source.project` when the file names one a lenient read can find, so a broken trigger stays listed under its project; it is absent only when no project can be read. - `armed` is `valid`, `enabled` and `triggers.enabled` together. `disarmed_reason` names the first one missing, in that order, as a sentence for a person: `the trigger file does not validate`, `the trigger is disabled`, or `triggers.enabled is off in config.yaml`. It is absent on an armed trigger. - `on_fire` and `permission` are the values in effect, not the values written: a file with no `on_fire:` reads `propose`, and one with no `permission:` reads `restricted`. `permission` is present only on a `create_task` action. - `poll` is the trigger's poll health. `seeded` says it holds a cursor, so its next poll judges events instead of seeding. `ok` and `error` are the last poll's result. `last_poll_at` and `last_fire_at` are `null` until each has happened. A trigger that has never polled carries the zero values shown above. A `type: schedule` source has no poll: `seeded` says its clock is anchored, and `last_poll_at` is 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](#one-workflows-full-definition) follows. A `404` means no file by that id. ### Writing a trigger Trigger writes follow [Writing a workflow](#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`](#writing-a-workflow), 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`](#writing-a-workflow) 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` (deprecated), `github_prs`, `issues`, `http` and `schedule`. The action types are `create_task` and the three reactions, `follow_up`, `retry` and `cancel`; `create_task` takes `issue`, a template rendering the `issue_id` it replays. On a GitHub or `issues` source, `events[]` names the events it emits, and `trusted[]` the ones a trigger may match without naming `allowed_actors` (on `issues`, the ones trusted even when they arrive by `sync`). `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`](cli.md#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: ```json { "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" } ``` - `matched` is the `match:` verdict. `match_miss` names the first key that failed, or `allowed_actors` for an author the list does not name. - `if` is the guard's verdict and `if_rendered` what it rendered to. `if` is absent when the trigger has no guard or the guard was not reached. - `dedupe_key` is the rendered key. `would_dedupe` says the ledger already holds a `fired` or `seeded` row for it. - `overrun` is the mode the trigger declared, absent for the `parallel` default, which is not checked at all. `concurrency_key` is the rendered group, `in_flight` the unsettled tasks found in it, and `would_skip`, `would_cancel` and `would_queue` the 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. - `action` is the request the trigger would replay, **unsent**: `type`, `method`, `path` and `body`. For `create_task` the body is a [`POST /v1/tasks`](#tasks) body, `paused: true` unless `on_fire: create` and `restricted: true` unless `permission: workflow`. A reaction also carries the `branch` it rendered and the `task_id` it resolved, with that id in `path`. Its body is a `/follow_up` or `/retry` body, likewise `paused` unless `on_fire: create`, and a `cancel` has none. `action` is absent when the pipeline stopped before rendering, or rendering failed. - `outcome` is the [ledger outcome](#the-delivery-ledger) the event would get. Here `fired` means "would be replayed": a dry run stops short of the replay, so it cannot know whether the route would refuse. - `error` says why the pipeline stopped: a template or `if:` that failed to render, an event with no `id` on a trigger with no `dedupe_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. - `seed` says 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 empty `events[]`. - `truncated` counts events past the 20-event cap on one poll, which were not judged. `refused` counts command output lines that were not events. - `cursor` is 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`: `error` says why, `events[]` is empty and the other fields are zero. - A `type: http` trigger is pushed and a `type: schedule` trigger is a clock: neither has a source to run once, so both are a `400`. Judge a supplied event with `/test` instead. ### 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`. ```json { "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](#permanent-delete). 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. ```sh 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=&issue_id=` | List. Fan-out lanes are **excluded** by default — `parent_id` lists one parent's lanes in merge order, `include_children=true` the flat everything. `issue_id` lists the tasks created from one [issue](#creating-a-task-from-an-issue) | | `POST` | `/v1/tasks` | `{ project_id, workflow, title, description?, fields?, base_branch?, branch_name?, existing_branch?, priority?, agent?, model?, effort?, github_pull?, issue_id?, merge_back?, paused?, restricted?, max_task_cost_usd? }` — `issue_id` creates the task [from an issue](#creating-a-task-from-an-issue), as its [main task](#the-issues-main-branch) unless `merge_back` asks for a side task. `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](#running-on-an-existing-branch). `paused`, `restricted` and `max_task_cost_usd` are the [create-time limits](#paused-restricted-and-capped-tasks). 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](#permanent-delete) | | `POST` | `/v1/tasks/import` | `{ path, task_id, project_id? }` — one archived task back out of a backup archive. See [Importing a task](#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](#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](#the-tasks-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`](task-lifecycle.md#failure-reasons) 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](files.md#transcripts) 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-mcp-endpoint). 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. ```sh curl -sS -X DELETE "http://127.0.0.1:PORT/v1/tasks/7?delete_branch=true" \ -H "Authorization: Bearer $TOKEN" ``` ```json { "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](#a-chat-on-a-stopped-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 `404`s. 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`](#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`](cli.md#vincent-task-import) calls. ```sh 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}' ``` ```json { "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](files.md#transcripts) starts over. `worktree_path` is cleared, `created_by_task_id` is cleared unless that task exists here, and `issue_id` is cleared unless that issue exists in the project the task lands in (the task keeps its issue snapshot), taking `issue_worktree` and `merge_back` with it; 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 `409`s carry `details.action: "import"`. There is no bulk import; the route is not an [MCP tool](#mcp). ### 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`](workflow-schema.md#type-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. ```sh curl -sS "http://127.0.0.1:$PORT/v1/tasks/12/workflow" \ -H "Authorization: Bearer $TOKEN" ``` ```json { "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: ```json { "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`](#workflows) follows. ### Replaying a create `POST /v1/tasks` is a 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. [`POST /v1/issues`](#issues) honours the same header under the same rules. ```sh 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** → `201` with 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 a `201`. The task exists, and this is it. - **Same key, a different body** → `409 invalid_state` with `details.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 `issue_id` or `github_pull` 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. There is no `github_issue` create field. It was removed in task 130.11, and because request bodies are decoded strictly, a body that still carries it is `400 validation_failed` (`unknown field "github_issue"`). A GitHub issue reaches a task by being [imported](#issue-sync) and then named by `issue_id`; a script that holds only the GitHub number finds the issue's id with `GET /v1/issues?project_id=P&remote_number=N`. A task created from a GitHub issue before then still serves `github_issue` on every task representation: the issue as it was at creation, a **snapshot** that is never re-read, and that the upgrade [backfilled](#issue-sync) into the project's issues and linked the task to. A task created from an imported issue serves a `github_issue` derived from the issue, so a consumer reading `.github_issue.number` keeps working. `github_pull` is a pull-request **number**. The daemon fetches the pull request, prefills `title` (`#N ` and the pull request title), `description` (its body plus a trailing `GitHub pull request #N: ` line) and a declared field named exactly `pull` carrying the bare number, and fills in only what this request left unset — **anything you send explicitly wins.** For `fields` and `description` that is decided by *presence*: a key sent with an empty value is a row somebody cleared on purpose and stays cleared, so `"description": ""` creates a task with no description rather than the pull request body. Only `title` keys on emptiness as well as absence, so `title` becomes optional when `github_pull` is given. An unusable integration is the same 409 with `details.reason` the GitHub endpoints return. What is different is the branch. **The task's `branch_name` is the pull request's head branch** — the top of the [branch chain](configuration.md#branch_template), 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_pull` link is written **at creation** with `source: "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 carries `branch: true` — this task's branch came from this pull request — and `fork: true` when 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}/retry` refuses `branch_override` with a `409`. 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`](task-lifecycle.md#failure-reasons). Every task representation also carries `workflow_origin`: which definition the `workflow` name resolved to at creation. ```json "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: ```json "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 — except on an issue's side task, which always records the main branch's tip it was cut from. `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`](configuration.md#fetch_base_branch), or a side task, which never fetches) 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](#holding-a-retry-or-a-follow-up) 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. **`409`** on an issue's [main task](#the-issues-main-branch) while another unarchived main task of the issue carries the same branch — an issue has one main branch; a sole main task may be renamed, and the main branch moves with it. 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](#holding-a-retry-or-a-follow-up) 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](#a-chat-on-a-stopped-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](task-lifecycle.md). 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: ```json { "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](#a-chat-on-a-stopped-task) 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`: ```json { "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 `include`s are expanded, or a fan-out tree past `fan_out.max_depth` from this task's own depth are all `400`s. 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](../guides/workflows.md#54-task-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: ```json { "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`](configuration.md#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. Five details worth knowing: - **A `queued` task may be waiting on a clock, not a slot.** Every task representation carries `queued_reason` and `admit_not_before` (RFC3339, or `null`). Both are `null` for an ordinarily queued task. Two reasons set them, and vincent tries again at that timestamp unattended in both cases: `usage_limit`, the agent's usage window being spent, and `retry_backoff`, a step's failed attempt being paced by its [`retry_backoff`](workflow-schema.md#step-fields). They are separate from `block_reason`, which still means only "stopped, needs a human" — the task is not blocked. Treat the set as open: a client should render whatever string it is given rather than switching on the two it knows. - **A `blocked` task says why in a sentence.** Every task representation carries `block_detail` beside `block_reason`: the daemon's own explanation of the block — for instance the worktree path that was already occupied — or `null` when there is none. It matters most for a task that blocked before any step ran, which has no step row to read. It is cleared with `block_reason`, and never carries URL credentials or the text of a GitHub error. - **List rows carry the board fields** — `project_name`, `step_total`, `step_name`, `status_message`, and `cost_usd` / `input_tokens` / `output_tokens` rolled up across every attempt — so a board renders without an N+1. Those are list-only; `GET /v1/tasks/{id}` serves the same numbers per attempt in `steps[]`. List rows also carry `github_pull`, the same pull request link the detail has (null for a task no pull request matched), which is what the board's `⇡#123` marker reads. Every task shape carries `parent_task_id`, `lane_id` and `lane_order`, all null for a root task. `GET /v1/tasks/{id}` and every row of `GET /v1/tasks` additionally carry `children` whenever the task has lanes, and omit it otherwise: ```json "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. The list computes the rollups of a whole page in one batched walk, so a row with lanes costs no extra request; a daemon older than task 129.18 serves `children` on the detail endpoint only. `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`](configuration.md#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: ```json "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.** `true` selects only archived tasks, `all` returns both. - **Every task representation carries `available_actions`** (the actions valid right now) and `pause_requested`, so clients never restate the state machine. While a [linked chat](#a-chat-on-a-stopped-task) is open it also carries `open_chat_id`, and `available_actions` is `["cancel"]` where cancel is legal (`blocked`, `awaiting_gate`) and `[]` otherwise. `open_chat_id` is omitted when no chat is open. The detail response adds `workflow_steps[]` — this task's snapshot, which is what edit-and-retry prefills an editor with, reflecting any earlier edit. A step spliced in by [`type: include`](workflow-schema.md#type-include) carries `resolved_from[]`, the chain of workflows it came through, outermost first. Task creation validates the agent/model/effort override: a known-invalid value is `400`, a catalog-unknown one is reported in `warnings[]` on the `201` body. It is also where a workflow's [includes](workflow-schema.md#type-include) 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`](configuration.md#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 | ```sh 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_usd` is `400 validation_failed`. The cap is inert on codex and cursor, which report no cost. - `restricted` goes through the same creation check a workflow's own `restricted` step does: if an agent step would run on an adapter that cannot restrict on this host — cursor on Windows — the create is `400 validation_failed` naming 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, or `null` when the task set none). ## Issues A project's backlog, owned by vincent: issues you file here and, later, issues imported from GitHub. An issue has no process and no worktree — work on it is a task created from it. | Method | Path | What | |---|---|---| | `GET` | `/v1/issues` | List, most recently updated first | | `POST` | `/v1/issues` | File one | | `GET` | `/v1/issues/{id}` | One, in full | | `PATCH` | `/v1/issues/{id}` | Edit fields and labels at the version you read | | `POST` | `/v1/issues/{id}/close` | Close, with a reason | | `POST` | `/v1/issues/{id}/reopen` | Reopen | | `GET` | `/v1/issues/{id}/comments` | The discussion thread, oldest first | | `POST` | `/v1/issues/{id}/comments` | Add a local comment | | `DELETE` | `/v1/issues/{id}` | Delete permanently, from any state | | `GET` | `/v1/projects/{id}/issue-labels` | The project's label catalogue | ```sh curl -sS -X POST "http://127.0.0.1:$PORT/v1/issues" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"project_id":1,"title":"Crash on cold start","labels":["bug"],"kind":"bug","priority":2}' ``` ```json { "id": 7, "project_id": 1, "title": "Crash on cold start", "state": "open", "kind": "bug", "priority": 2, "author": "ada", "labels": ["bug"], "source": null, "active": false, "task_count": 0, "lane": "open", "attention": false, "version": 1, "created_at": "…", "updated_at": "…", "body": "", "available_actions": ["close"], "tasks": { "count": 0, "active_ids": [] }, "editable": ["title", "body", "labels", "kind", "priority"], "commentable": true } ``` - **Create** takes `{ project_id, title, body?, labels?, kind?, priority? }` and answers `201`. `title` is at most 1 KiB, `body` (Markdown) at most 64 KiB, a label name at most 64 bytes, `kind` a lowercase token, `priority` 0 none, 1 urgent … 4 low. An unknown project is `404`. There is no `author` field: the daemon records the OS user it runs as, or `task N` when an agent step files the issue over [MCP](#mcp), with `created_by_task_id` set. The `Idempotency-Key` header works exactly as on [`POST /v1/tasks`](#replaying-a-create): same key and body replays the issue under a `201`, same key and a different body is `409 idempotency_key_reused`. `vincent doctor` counts these keys under `database.table_rows.issue_idempotency_keys`. - **List** returns a bare array whose rows omit `body`. Filters: `project_id`, `state` (`open`, `closed`; repeatable), `label` (repeatable — every one must match, case-insensitively), `kind`, `q` (a substring of the title or body; `%` and `_` are literal), `source` (`local` or `github`), `remote_number`, `sort` (`updated`, the default, or `created`), `limit` and `offset`. `remote_number=N` keeps the issues whose GitHub remote has number `N` — the way a script that holds a GitHub issue number finds the vincent issue to create a task from. It requires `project_id`, because a number means nothing across projects: without it the request is `400 validation_failed`. An empty array means the project has not imported that issue (yet); a [sync now](#issue-sync) and a second look is the way to tell. `lane` (repeatable; any of them matches) keeps the issues in that lane — `open`, `in_progress`, `hand_off` or `done` — and is applied before `limit` and `offset`, so a page is a page of that lane. It combines with `state` like every other filter, so `state=open&lane=done` is empty. Any other value is `400 validation_failed`. - **Lanes.** Every row carries `lane` and `attention`, derived from the issue and its **root** tasks (fan-out lanes never count) and never stored: | `lane` | When | |---|---| | `open` | The issue is open and no root task is unfinished or done: no task yet, or only cancelled ones | | `in_progress` | The issue is open and some root task is not `done`, `aborted` or `archived` — `paused` included | | `hand_off` | The issue is open, every root task is finished, and at least one is `done` (or was archived from `done`): the work is back with you to close | | `done` | The issue is closed — whatever its tasks are doing. Reopening returns it to the lane its tasks give it | `attention` is `true` while some root task is `awaiting_input`, `awaiting_gate` or `blocked`; it is reported on a closed issue too, whose lane is `done` regardless. For an open issue, `active` is exactly `lane == "in_progress"`. - **Get** adds `body`; `available_actions`, what a person may do from this state; `tasks.count` and `tasks.active_ids` over the root tasks created from the issue (fan-out lanes never count); `editable`, the fields a `PATCH` may touch; `commentable`, whether a local comment is taken — `false` only while the GitHub remote is live, so it is `true` on an issue whose remote moved or is missing even though its `body` is not editable; and `source` — `{ provider, repo, number, url, remote_state, last_synced_at, status }` for an imported issue, `null` for a local one. `last_synced_at` is when the importer last wrote the issue from the remote. `status` is absent while the remote issue is live, `moved` when it was transferred, and `missing` when it was deleted or is no longer found; nothing is deleted locally either way. `remote_state`, `last_synced_at` and `status` are omitted when empty, and list rows carry the same `source`. An imported issue also carries `sync` — `{ state, reason?, last_synced_at? }` — its [state write-back](#state-write-back); a local issue has none. `?workflow=NAME` adds `prefill: { title, description, fields }`, what [creating a task from the issue](#creating-a-task-from-an-issue) with that workflow would fill in; an unknown workflow is `400`. - **Patch** takes `{ version, title?, body?, labels?, add_labels?, remove_labels?, kind?, priority? }`. `version` is required — send the one you read. `labels` replaces the set and cannot be combined with `add_labels` or `remove_labels`. An empty patch, a missing `version` and a `state` field are `400`: close and reopen are their own routes. Fields and labels land as one write that bumps `version` once. A patch that changes nothing is a `200` that writes nothing. - **Close** takes `{ reason?, duplicate_of? }`: `completed` (the default), `not_planned` or `duplicate`. `duplicate_of` names the issue this one duplicates; it is allowed only with `duplicate`, must be another issue in the same project, and is cleared by reopening. **Reopen** takes `{}`. - **Comments** — `GET /v1/issues/{id}/comments` lists `{ comments: [{ id, issue_id, author, body, remote, remote_key?, created_at, updated_at }] }`, oldest first; `remote` marks a comment mirrored from GitHub, read-only, and `remote_key` is its GitHub id. `POST` takes `{ body }` and answers `201` with the comment; the daemon records its author as it does an issue's. An empty body, or one over 64 KiB, is `400`. There is no `Idempotency-Key`. Nothing is ever posted to GitHub: a comment edited there is updated in place, and one deleted there stays. - **Delete** answers `204` from any state, unless one of the issue's [main or side tasks](#the-issues-main-branch) has not finished: then it is `409 issue_has_live_main_task` naming it — a main task first, a side task once no main task is live. An imported issue leaves a tombstone so it is never imported again, nothing is written upstream, and tasks created from it keep running with `issue_id` cleared. It is not an MCP tool. - **Labels** — `GET /v1/projects/{id}/issue-labels` lists `[{ name, color?, description?, source, issue_count }]`, sorted case-insensitively. `source` is `local` or the provider a label was imported from. | `409` | When | `details` | |---|---|---| | `issue_changed` | `PATCH` with a stale `version` | `reason`, and `issue`: the issue as it is now — re-apply your edit to it and send its `version` | | `issue_mirrored` | `PATCH` touching an imported issue's `title`, `body` or labels, which mirror GitHub. `kind` and `priority` stay editable. Also a comment on an issue whose GitHub remote is live — one whose remote moved, is missing or was deleted takes comments | `reason` | | `forge_write_needs_human` | `close` or `reopen` of an issue imported from GitHub by an agent: an [MCP](#mcp) tool call, or a request carrying `X-Vincent-Task-Id` or `X-Vincent-Chat-Id`. Its state is written back to GitHub, and only a human's act does that. A local issue, or one whose remote moved or is missing, is not refused | `reason` | | — | `close` on a closed issue, `reopen` on an open one | `state` | ### Creating a task from an issue `issue_id` on [`POST /v1/tasks`](#tasks) creates a task from an issue and links the two: ```sh 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,"workflow":"fix-issue","issue_id":7}' ``` The daemon freezes the issue onto the task as a snapshot — what [`.Issue`](workflow-schema.md#template-context) renders — and prefills `title`, `description` and declared fields from it, on the same rules as `github_pull`: anything you send explicitly wins, by presence for `fields` and `description`, and a blank or absent `title` takes the issue's. `GET /v1/issues/{id}?workflow=` previews exactly that prefill. No GitHub call is made, even for an imported issue. - An unknown `issue_id`, an issue in another project, and `issue_id` beside `github_pull` are `400 validation_failed` and create nothing. - A **closed** issue still creates the task — follow-up work on a closed issue is legitimate — and `warnings` says the issue is closed. - Every task representation carries `issue: { id, title, state, source? }`: the issue as it is now, or the snapshot once the issue has been deleted. `null` for a task created without one. - For an issue imported from GitHub, `github_issue` is filled in from the snapshot, so a consumer reading `.github_issue.number` keeps working, and the task's [`compare_url`](#github-pull-requests) carries `Closes #N` for an issue in the same repository. - `GET /v1/tasks?issue_id=N` lists the issue's tasks — the root tasks its `tasks.count` counts; fan-out lanes inherit the link but are not listed. ### The issue's main branch A task created with `issue_id` is the issue's **main** task by default: `issue_worktree` on the task reads `"main"`. The first main task's branch — the one you named, or the one the usual [branch naming](#tasks) produced — becomes the issue's **main branch**, and every later main task of the issue runs on it, in the same directory: when a later main task is admitted, the main task before it that has finished hands it its worktree, uncommitted changes included. The later task's `base_sha` is the branch's tip at that moment, and the earlier task records the same commit as where its own work ends, so its [`/commits` and `/diff`](#transcripts-and-diffs) stop there rather than growing with later work. A later main task does not read back as `adopted_branch` — the branch is vincent's, cut by the first main task. If the earlier task is mid-merge, rebase, cherry-pick, revert or bisect, the later one blocks `repo_operation_in_progress` and the earlier one keeps the directory; if the main branch is checked out in the project's own checkout, it blocks `issue_branch_checked_out` and never runs there. A `follow_up` or chat on the earlier task is then refused `409 issue_worktree_moved`, naming the holder. While another unarchived main task of the issue carries the branch, archive does not delete it even when it has no commits past the issue's base branch. The issue serves it as `main_worktree`: ```json "main_worktree": { "branch": "vincent/12-lock-file-leaks", "occupant_task_id": 12, "occupant_state": "running" }, "side_active": 1, "merge_backs_pending": 0 ``` `occupant_task_id` is the main task that has started and not yet finished — it holds the main worktree even while `blocked`, `paused` or `awaiting_gate` — or a `done` or `aborted` main task whose worktree a [linked chat](#a-chat-on-a-stopped-task) still has open. It is `null` when nothing holds it, and `main_worktree` is absent while the issue has no main branch. When every main task has been archived the issue has none, and the next main task starts a fresh branch. `occupant_state` is the occupant's task state, `null` with it. `side_active` counts the issue's side tasks that are neither finished nor archived, and `merge_backs_pending` its merge-back tasks likewise; every row of `GET /v1/issues` carries all three, read in the list's own query. Creating a main task while the main worktree is occupied names the occupant in the `201` as `main_worktree_occupant_task_id`. The new task stays `queued` while the occupant holds the worktree — no error, no reason — and is admitted once it is free; at most one main task of an issue is in progress at a time. `merge_back` asks for a **side** task instead — its own worktree, to be merged back into the main branch when it is done: ```sh 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,"issue_id":7,"merge_back":{"on_conflict":"block"}}' ``` `on_conflict` is `block` (stop for a human on a conflict; the default) or `agent` (try a resolver first). A side task is cut from the issue's main branch, never the project's base: leave `base_branch` out, or name the main branch. It is cut from the main branch as it is on your machine, without fetching it and without moving it, so the main task's worktree is never touched, and its `base_sha` is the main branch's tip at the cut. The task reads back with `issue_worktree: "side"` and `merge_back: { "on_conflict": … }`. A main task carries `merge_back: null`, and a task with no issue, a fan-out lane, or one created before roles existed carries `null` in both. A merge-back task names the side task it merges in `merge_source_task_id`, which is `null` on every other task and once that side task is deleted. | Status | When | |---|---| | `400` | `merge_back` without `issue_id` | | `400` | `merge_back` on an issue that has no main branch yet — create a main task first | | `400` | `merge_back` while the main branch is not in git yet — the first main task is still queued or paused, so the ordinary "`base_branch` does not resolve to a local branch" | | `400` | `merge_back` with a `base_branch` other than the issue's main branch | | `400` | `merge_back` together with `branch_name` or `existing_branch` | | `400` | `merge_back.on_conflict` other than `block` or `agent` | | `400` | A main task whose `branch_name` or `existing_branch` names a branch other than the issue's main branch | A chat [handed off](#chats) with `issue_id` becomes the main task when the issue has no main branch yet, and its branch becomes the main branch; otherwise it becomes a side task, merged back on the body's `merge_back.on_conflict` — `block` when the body has no `merge_back`. On an issue with no main branch yet a handoff's `merge_back` is accepted and inert: the task is the main task, the chat's branch the main branch, and the task reads back `merge_back: null`. Only that refusal and the `branch_name` / `existing_branch` one are lifted, since a handoff always adopts the chat's branch; every other `400` in the table holds, with the same message. #### Merge-back tasks When a side task finishes `done` with commits of its own, vincent creates a **merge-back** task for it in the same moment: a queued main task of the issue, titled `Merge task {side} into issue #{issue}`, with `workflow: "__merge_back"` and the side task's priority, `on_conflict` and agent, model and effort. It waits for the main worktree like any main task and is admitted in the usual order — priority, then creation, which is when the side task finished — so at one priority merge-backs run in the order their side tasks completed. Then it merges the side task's branch into the main branch with `--no-ff` and its title as the commit message. A clean merge ends `done`. On a conflict, `block` leaves the main worktree mid-merge and blocks `merge_conflict`: resolve and stage the files there, then retry, or cancel — which runs `git merge --abort` so the next main task gets a clean worktree. `agent` first runs a built-in resolver with the side task's agent and falls back to the block when it fails or leaves conflict markers. `skip` ends it without merging, aborting a conflicted merge it left behind; so does `cancel`, from any state, the resolver's run included. - A side task with no commits past its `base_sha` creates no merge-back, and neither does one whose branch is already on the main branch — a follow-up that added nothing after an earlier merge-back. - While a side task's merge-back is still pending, finishing the side task again — a follow-up — creates no second one; the pending one merges the branch as it is when it runs. Once it has finished, the next `done` creates a new one. - If every main task of the issue was archived, the merge-back checks the old main branch out again and merges into it. If that branch was deleted too it blocks `merge_target_missing`. - A deleted side task, or a side branch gone from git, blocks `merge_source_missing`. ## 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](#a-chat-on-a-stopped-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-mcp-endpoint) — 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, save the two `merge_back` refusals [a handoff lifts](#the-issues-main-branch). `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. That includes `issue_id`: the handed-off task is [linked to the issue](#creating-a-task-from-an-issue) exactly as a direct create is, and a closed issue's warning lands in the task's `warnings`. 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 ```bash 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](#running-on-an-existing-branch) — 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: ```json {"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`](#get-v1agents) 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. ```bash 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`](#tasks)'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`](#tasks) 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: ```bash 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_branch`** are refused on a chat opened on a task with `409 chat_linked_to_task` and `details.task_id`. A closed one can be [deleted](#permanent-delete) without `delete_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_usd` nor the task's own cost rollup sees them. `max_parallel_chats`, `agent_timeout` and `input_timeout` apply as to any chat. - **A task that runs in a [container](configuration.md#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 ```bash 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](#a-chat-on-a-stopped-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](configuration.md#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. ```json { "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`** — `skills` is 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. A `probe_error` beside it means the latest probe failed and this is the last list that succeeded, obtained at `probed_at`. - **`unsupported`** — the agent cannot list its skills, with the reason in `unavailable_reason`. Today cursor answers this. - **`unknown`** — nobody can say: the probe failed with no earlier list (`probe_error`), the chat's `agent` names no adapter this daemon has (`probe_error`), or the chat's task is configured to run in a [container](configuration.md#container) and that container is gone (`unavailable_reason`, and `work_dir` is 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 in `skills`, 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 ```bash 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](#a-chat-on-a-stopped-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. ```json { "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 add`ed is usually exactly the file you want to point an agent at. - **`.gitignore` is honoured**, so `node_modules/` and a local `.env` stay out — and a **committed** secret is listed, because it is in the repository and the agent can already read it. See the [security model](../security-model.md). - **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](configuration.md#container) is listed **on your host**, unlike its [skills](#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 ```bash 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. ```bash 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](#transcripts-and-diffs) has. Add `?format=normalized` for vincent's own vocabulary instead of the agent's dialect. ### Following one ```bash 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](#events-sse)'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 to `idle`. - **`timeout` / `input_timeout`** — the turn ran past [`defaults.agent_timeout`](configuration.md#defaults), or the chat sat in `awaiting_input` past `defaults.input_timeout`. The process tree is killed, the chat returns to `idle` and its `max_parallel_chats` slot 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](../guides/workflows.md#the-vincent-environment) 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`](cli.md#vincent-status), which reads both and needs no arguments beyond the message. The path names a **step id**, not a `step_runs` row id, because a step knows which step it is and cannot know its row. It names a step rather than only the task because a `parallel` group's sub-steps share one task and run at the same time; within one task a step id has at most one running row. What the daemon does with the message: - **Bounds it rather than validating it.** It is flattened to a single line, stripped of control characters and truncated to 256 bytes. Over-long text is never a `400` — a step reporting progress should not fail because it was wordy. An empty message clears the status. - **Refuses a step that is not running**, with `409 invalid_state`. An unknown task is `404`. A write is never silently dropped, so a script still narrating after its step was killed finds out. - **Paces writes without rejecting them.** Two writes for one step run inside one second coalesce to the later value, which lands when the second is up. The first write after a quiet period is always immediate. - **Announces the change** as the durable [`task.status_changed`](#state-events--durable) event — but only when the stored value actually changed. Where it shows up: `status_message` on every step-run object from `GET /v1/tasks/{id}` and `GET /v1/tasks/{id}/steps`, and on each row of `GET /v1/tasks`, denormalized from the task's **newest** step run so a board never fetches step rows for it. It is `null` when the step said nothing, which is the ordinary case — only `agent` and `command` steps run a process, and one only speaks if its prompt or script was written to. **It is not a failure reason.** `failure_reason` is a closed set of daemon-authored constants and is vincent's own verdict; `status_message` is free text the step chose, possibly long before it died. Render it as the step's last status, not as the cause of anything. ## Transcripts and diffs ``` GET /v1/tasks/{id}/steps/{run_id}/transcript?offset=&tail=&format= GET /v1/tasks/{id}/diff?by= GET /v1/tasks/{id}/commits ``` The transcript is the attempt's JSONL file, ranged: - `offset=` (bytes) and `tail=` (last N bytes) are mutually exclusive. - `tail` opens at the **start of the record its byte count lands in**, so a window narrower than the last record still returns that record rather than nothing. `offset` is taken as given. - The body always ends on a complete line, and `X-Next-Offset` reports that boundary — never mid-record, so a follow-up fetch on a file still being appended to resumes cleanly. - `format=normalized` maps every line through the owning adapter's parser into the live-output shapes plus `agent.result`, `agent.error`, `agent.input_echo`, `agent.conversation_reset`, the `vincent.*` kinds, and `agent.raw` for anything unrecognized. That is one render path for live tail and scrollback alike. Absent, you get the raw file byte for byte. - `agent.run_header` carries `work_dir` and `available_tools` — what the CLI announced about the run before starting it. `agent.result` additionally carries `duration_ms`, `api_duration_ms`, `num_turns`, `stop_reason`, `terminal_reason`, `cache_read_tokens`, `cache_write_tokens`, `model_usage` and `permission_denials` where the adapter reports them; `agent.tool_result` entries carry `verb` and `blocked`; and any record may carry `parent_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](../guides/agents.md#claude-code) fills all of them today. [Cursor](../guides/agents.md#cursor) fills `work_dir` (never `available_tools`), `duration_ms`, `api_duration_ms`, `cache_read_tokens` and `cache_write_tokens`, and [Codex](../guides/agents.md#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 its `parent_call_id`: | Type | Fields | |---|---| | `agent.subagent_started` | `call_id`, `description`, `subagent_type`, `background` | | `agent.subagent_progress` | `call_id`, `description`, `subagent_type`, `tool_uses`, `total_tokens`, `duration_ms`, `last_tool` | | `agent.subagent_finished` | `call_id`, `status`, `summary`, `tool_uses`, `total_tokens`, `duration_ms` | `status` is the CLI's own word, passed through (`completed` and `failed` are the ones seen so far). `summary` is the subagent's report cut to one line; the report itself is only in the raw transcript. These three records carry no `parent_call_id` themselves: they are the main agent's lines about a subagent. The call that spawned a background subagent reports an `agent.tool_result` entry with `verb: "started in background"` and no `summary`. Every key is omitted when unreported, and only Claude Code produces any of it. The [output pane](../guides/tui.md#when-the-agent-runs-subagents) draws a record carrying `parent_call_id` nested, behind a rail. A claude transcript range fetched with `tail=` or `offset=` that starts after every earlier line of a failed subagent can return that subagent's finish as `agent.raw`: a failed finish line does not say it is a subagent's, so it is recognized from the lines before it. - `agent.plan` carries `items` (`[{text, completed}]`) and `plan_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_output` carries `output`, `truncated`, `call_id` and `name` — what a command printed, which `agent.tool_result` deliberately never carries, capped with the cut reported rather than silent. `agent.result` additionally carries `reasoning_tokens`. Only [Codex](../guides/agents.md#codex) fills these today, and the same omitted-when-unreported rule applies. - `agent.patch` carries `patch`, `truncated`, `call_id` and `name` (and `parent_call_id` when 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 its `agent.tool_result` entry, whose `summary` is the line delta `+N −M` (and whose `verb` is `updated` for a `Write` that overwrote a file). Only [Claude Code](../guides/agents.md#claude-code) fills it, and not for a subagent's edits, whose results carry no patch. - `agent.skill` reports that a skill's content entered the conversation: | Field | Meaning | |---|---| | `name` | the skill as the CLI resolved it, namespace included | | `args` | the arguments it was invoked with, on one line | | `by` | `agent` for one the model loaded, `human` for one your message invoked | | `call_id` | the `Skill` tool call an agent's load came from — the same `call_id` as its `agent.tool_use` and `agent.tool_result` | | `forked` | `true` for a skill that ran as its own sub-run (Claude Code's `context: fork`); absent otherwise | | `error` | the 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.md` is never on the record — it is in the raw transcript. Only [Claude Code](../guides/agents.md#claude-code) produces it: a skill the model loads (`by: "agent"`, with `call_id` and `args`), and a skill your own message invoked (`by: "human"`, with the arguments you typed — or `forked: true` and no `args` for 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 with `tail=` or `offset=` that starts after the `Skill` call returns that load without `args`. A `Skill` call Claude Code refused yields no `agent.skill`; its `agent.tool_result` reports the refusal. Codex and Cursor report no skill loads, even when a skill ran. - `agent.input_echo` is 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 `/name` it could not resolve. It carries only `type` (and `parent_call_id` when set) and has no live chunk; the echoed text is in the raw transcript. It is not `agent.raw`, so it is not counted as unrecognized. - `agent.conversation_reset` marks the point at which the agent CLI threw away its own conversation and started a new one — what `/clear` does 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 only `type` (and `parent_call_id` when 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. Unlike `agent.input_echo` it **does** stream as a live chunk, with an empty payload. Only [Claude Code](../guides/agents.md#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. An issue's [main task](#the-issues-main-branch) that handed its worktree on, or was archived, is diffed in the project repository instead, ending at the commit its branch stood at when it let go. `?by=lane` asks for the same change **split by the fan-out lane that produced it**, as JSON rather than as `text/plain`: ```json { "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. `GET …/commits` lists the commits the task made on its branch, oldest first: ```json [ {"sha": "9c1e…", "subject": "Add the handler", "author_time": "2026-09-29T10:12:03Z"}, {"sha": "4ab2…", "subject": "Merge lane 'api' of task 42", "author_time": "2026-09-29T10:40:17Z", "lane_id": "api", "child_task_id": 42} ] ``` It reads the task's branch in the project repository rather than its worktree, so it **still answers after the task is archived**. For a live task that means committed work only. The range starts at the same base `…/diff` uses — the recorded `base_sha`, else the base branch — and follows the task's own first-parent chain, so the commits made inside a fan-out lane are not listed but the merge that joined the lane is, carrying the same `lane_id` and `child_task_id` a `?by=lane` section does. Both keys are omitted on every other commit. `author_time` is RFC3339 UTC. For an issue's [main task](#the-issues-main-branch) that handed its worktree on, or was archived, the range ends at the commit the shared branch stood at when it let go, not at the branch's tip. A branch with nothing past its base is `200 []`. It is a `409 invalid_state` when the task has no branch yet (it was never admitted), when the branch no longer exists — whether archive removed it because it had no commits or someone deleted it — and when the merge-base cannot be computed. A daemon that predates this route answers its router's `404 not_found` with the message `no such endpoint`, which is how a client tells an older daemon from an unknown task. ## 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](#chats) 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 issue.created issue.updated issue.state_changed issue.labels_changed issue.comment_added issue.comment_updated issue.deleted issue.sync_changed issue.lane_changed 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-ID` starts live at the next committed event.** The stream never replays history unasked: catch-up is a REST snapshot first, then the stream. - `task.deleted` and `chat.deleted` carry `{ id, title }` and announce a [permanent delete](#permanent-delete). They exist even though there is no `task.archived` type, and for the reason there is not one: an archive *is* `task.state_changed` with `to: 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.restored` carries `{ id, title }` and announces a task [imported from a backup](#importing-a-task). Unlike `task.deleted` it carries the task id, so it also reaches the per-task stream. It is not a state change and triggers no notification. - The `issue.*` events carry the issue's `id` and `by` (`human`, `agent` or `sync`) beside what moved: `changed` field names on `issue.updated`, `{ from, to, reason }` on `issue.state_changed`, `labels` (the whole set) with `labels_added` and `labels_removed` on `issue.labels_changed`, `comment_id` on `issue.comment_added` and `issue.comment_updated` — the latter a mirrored comment edited on GitHub, always `by: sync`. A sync refresh's `issue.updated` adds `labels_added` and `labels_removed` when `changed` holds `labels`, and `from`, `to` and — on a close — `reason` when the state moved; these deltas are what an [`issues` trigger](../guides/triggers.md#issues-watch-the-projects-issues) reads. None carries a title, body or comment text, and none reaches a per-task stream. `issue.state_changed` adds `duplicate_of` when a duplicate close names one. Follow one project's issues with `?types=issue.created,issue.updated,issue.state_changed,issue.labels_changed,issue.deleted&project_id=N`; `Last-Event-ID` resumes them like any durable event. A `PATCH` that edits fields and labels emits `issue.updated` and `issue.labels_changed`, and one that changes nothing emits nothing. - `task.created`, `task.state_changed`, `task.deleted` and `task.restored` carry `issue_id` when the task belongs to an issue — a fan-out lane included — and omit it otherwise, so a client can tell which issue a task event concerns without fetching the task. - `issue.lane_changed` carries `{ id, from, to, task_id?, by? }` and announces that an issue moved between [lanes](#issues) (`open`, `in_progress`, `hand_off`, `done`). `task_id` names the root task whose create, state change, delete or restore moved it; `by` is the actor of a close or reopen; exactly one of the two is set. It is written in the same commit as the event that caused it — the task event, `issue.state_changed`, or a sync refresh's `issue.updated` — and immediately after it, and only when the lane actually moved: a fan-out lane never moves its issue's lane, creating or deleting an issue writes none, and a task finishing on a closed issue writes none. Like the other `issue.*` events it never reaches a per-task stream. A board can re-list on it instead of on every task event. A `type: issues` [trigger](../guides/triggers.md#issues-watch-the-projects-issues) that names `match.action: lane_changed` fires on it. - `issue.sync_changed` carries `{ project_id, ok, reason? }` and fires only when a project's [issue sync](#issue-sync) flips between ok and failing, so a sync failing on every tick is one event, not one per tick. A project's first failure announces; its first success does not. There is no bulk import event: an import emits one `issue.created` per new issue, so debounce. - There is no separate `task.archived` or `task.awaiting_input` type. Both are `task.state_changed` with the appropriate `to`; the `awaiting_input` payload additionally carries the request kind and a one-line summary, which is the alert clients key off. The full request comes from `GET /v1/tasks/{id}`. - `task.step_advanced` carries `{ current_step }` when the engine moves the cursor without a state change, so a board's `k/n` tracks a run instead of freezing. - `task.children_changed` carries `{ task_id, child_id, to_state }` and is emitted on **every** fan-out ancestor when a descendant is created or transitions — re-fetch the `children` rollup when you see one. It exists because the per-task stream filters on `task_id` alone, so a root's stream would otherwise never see a depth-2 transition. - `task.status_changed` carries `{ task_id, step_id, message }` when a running step changes what it says about itself — see [Step status](#step-status). It is on the durable side deliberately, so a client that blinks recovers the message through `Last-Event-ID`. It is emitted only when the stored value actually changed, so a step re-asserting the same line does not wake you. - `agent.quota_changed` carries `{ agent, spent, resets_at, source }` and no `task_id`: the fact is about an adapter, not about any one task. It is emitted when a `usage_limit` stop is observed, 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, `spent` and `resets_at` come from the tightest window, and `resets_at` is `null` when that window named none. Re-fetch [`quota`](#usage-quota) from `/v1/agents` or `/v1/info` when you see one. - The `chat.*` events belong to the [chat](#chats) family and carry the chat's `project_id`, so `?project_id=` filters them the way it filters a task's. Each payload is `{ id, title, state }`; `chat.turn_changed` adds `turn_id`, `turn_seq`, `turn_state` and — when the turn failed — `fail_reason`, and `chat.handed_off` adds `handoff_task_id`, so a follower can link the chat to its task without a fetch. Every event of a chat [opened on a task](#a-chat-on-a-stopped-task) adds `linked_task_id`, and `chat.closed` announces that such a chat ended — re-fetch that task too, because its lock just lifted. Opening or closing one emits no `task.*` event: the task's state did not change. Re-fetch `GET /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's `result_text` is what you read. - `task.github_pull_changed` carries `{ 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 its `updated_at` is untouched, because the link is a fact about GitHub rather than about the task's own progress. Re-fetch [`/v1/tasks/{id}/github/pull`](#github-pull-requests) when you see one. - `trigger.fired` carries `{ trigger_id, delivery_id, action }`, where `action` is the action type. The event's `project_id` is the trigger's project and its `task_id` is 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](#the-delivery-ledger) row is recorded, and `fired` is 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 own `task.created`. - `trigger.poll_changed` carries `{ trigger_id, ok, error }` and no `task_id` or `project_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-read [`GET /v1/triggers`](#triggers) on 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, and - `offset` — the byte position in that attempt's transcript file *after* its line was written. Together those make the catch-up seam exact: fetch the transcript, then discard buffered chunks whose `run_id` matches the attempt you fetched and whose `offset` is at or before the fetch's `X-Next-Offset`. `run_id` is load-bearing on its own, because offsets restart at zero in every attempt's file. `Last-Event-ID` on the per-task stream resumes its **durable** events only; live output is not replayable. ### Back-pressure The two kinds fail differently on purpose. A slow subscriber has **live output chunks dropped** — the transcript is the durable copy. A slow subscriber to **durable state events is disconnected** instead, so it reconnects and resumes from the events table via `Last-Event-ID`. Fan-out is post-commit only: the store publishes after the database has recorded the event. --- ## 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](../guides/mcp.md)**. 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 | | `DELETE /v1/issues/{id}` | Same: the delete is permanent, and an imported issue's tombstone outlives it. Every other issue route is a tool | | `POST /v1/projects/{id}/issues/sync` | It asks the importer to poll early and flushes vincent's pending state writes to GitHub, so it stays a human act. Its `GET` is the tool `project_issue_sync_status` | | `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` and `issue_create` additionally take 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](../security-model.md). --- ## See also - [Driving vincent from an agent](../guides/mcp.md) — the MCP surface. - [Scripting vincent](../guides/scripting.md) — worked examples. - [Task lifecycle](task-lifecycle.md) — what the action endpoints do. - Spec [§13](https://github.com/lezli01/vincent/blob/master/docs/spec.md) — the normative definition. {% endraw %}