{% raw %}

# Configuration

The daemon reads one file, `{config_dir}/config.yaml`. It is written with
commented defaults on first start, and the daemon **watches it and hot-reloads
valid changes** — there is no apply step.

- [Reload semantics](#reload-semantics)
- [The default file](#the-default-file)
- [Keys](#keys)
- [Per-project settings](#per-project-settings)
- [Environment variables](#environment-variables)
- [Reading the config in effect](#reading-the-config-in-effect)

---

## Reload semantics

| Situation | Behavior |
|---|---|
| Valid edit while running | Applied immediately |
| **Invalid edit** while running | Rejected and logged; the **last good** configuration stays active |
| Invalid config at **startup** | Fatal — the daemon refuses to start and says which key |
| `listen:` changed | Ignored until the next daemon restart, with a warning |
| Unknown key | Rejected (strict decoding) — a typo is an error, not a setting that silently never applied |

Omitted keys keep their built-in defaults, so a partial file is fine.

## The default file

This is what first start writes, verbatim:

```yaml
# vincent daemon configuration (spec §12.3).
# Generated with defaults on first start. Edit freely: the daemon watches this
# file and hot-reloads valid changes. Invalid edits are logged and rejected,
# and the last good configuration stays active. Changes to "listen" take
# effect on the next daemon restart.

# Address the HTTP API binds to. Loopback only. Port 0 picks an ephemeral
# port, published for clients in {data_dir}/daemon.json.
listen: 127.0.0.1:0

# Global cap on concurrently running tasks. Per-project caps are configured
# on each project.
max_parallel_tasks: 3

# Naming convention for the branch each task's worktree is cut on. It is a
# Go text/template rendered with .ID, .Slug, .Project, .Workflow and .Date;
# the result is sanitised into a legal ref. A project may override it, and
# the built-in below is what applies when neither is set. A template that
# does not compile is refused and the previous one stays in force.
#
# branch_template: "vincent/{{.ID}}-{{.Slug}}"

# Fallback step timeouts, used when a workflow step declares none.
# input_timeout bounds each wait for an answer to an agent's input request.
# Both clocks bound a chat turn as well as a workflow step.
# (awaiting_input, §7.4); on expiry the attempt fails under the retry policy.
defaults:
  agent_timeout: 60m
  command_timeout: 15m
  input_timeout: 24h

# Delete a task's branch when it is archived and carries no commits past the
# base it was cut from — the branch a workflow that never writes to the
# repository leaves behind. A branch holding any commit is always kept, and a
# check git cannot answer keeps the branch too.
delete_empty_branch_on_archive: true

# Also delete that branch's upstream counterpart, when it has one. Off by
# default and honoured only when a human archives the task: a forge is shared
# with other people and the deletion cannot be undone. Nothing happens here
# unless delete_empty_branch_on_archive is also on.
delete_remote_branch_on_archive: false

# Refresh a task's base branch from its remote before the task's worktree is
# created, and start the task branch at the fetched commit rather than at
# whatever your local base happened to be. Your own checkout is never touched:
# the local branch keeps its SHA and its working tree, so "git log <base>" in
# your checkout will no longer match what tasks build on.
#
# The remote comes from the base branch's own upstream configuration, never
# from a guess at the name "origin". A branch with no upstream, a repository with no
# remote, an unreachable host and a fan_out lane based on its parent's branch
# all fall back to the local base and create the task normally.
fetch_base_branch: true

# Transcripts of archived tasks older than this many days are pruned.
transcript_retention_days: 90

# Per-attempt transcript cap. A step whose transcript passes this fails with
# transcript_limit rather than filling the disk.
transcript_max_bytes: 512MB

# Ceiling on what one task may spend, in US dollars, summed over every attempt
# of every step it runs. Past it the task blocks with cost_limit at the next
# attempt boundary, so expect to overshoot by at most one attempt; raise this
# and retry to carry on. 0 disables it, which is the default.
#
# It counts one task. Each fan_out lane is its own task and gets its own
# budget, so a tree of twenty lanes may spend twenty times this; cap the whole
# tree with max_tree_cost_usd. Only agents that report cost are counted —
# codex and cursor report none, so the cap is inert on them.
max_task_cost_usd: 0

# Ceiling on what one fan_out tree may spend, in US dollars: a root task and
# every lane below it at any depth, summed over every attempt. Past it, the
# task whose attempt crossed the line blocks with tree_cost_limit. Every task
# still working in the tree may finish one more attempt before it blocks too.
# Raise this and retry the parent to carry on. 0 disables it, which is the
# default. Where both caps are passed at once the block is cost_limit.
max_tree_cost_usd: 0

# How long a task waits before trying again after its agent reported that the
# usage quota for the window is spent, when the CLI named no reset time. When
# it does name one, that wins and this is unused. The task keeps its place in
# the queue and holds no slot while it waits.
usage_limit_recheck_interval: 15m

# What a recognized usage-limit stop does: always | reported_only | never.
# always holds the task — it returns to the queue, waits out the reset and
# consumes no retry — and is today's behaviour, so leaving this alone changes
# nothing. reported_only holds only when the CLI actually named a reset time,
# and blocks when the wait would be the estimate above — the case where
# vincent is guessing. never always blocks.
#
# Only claude reports a spent quota at all; codex and cursor recognize no such
# wording, so this key is inert on them.
usage_limit_auto_continue: always

# Daemon log verbosity: debug | info | warn | error.
log_level: info

# Record how every step was actually invoked in its transcript: resolved
# agent/model/effort, permission mode, working directory, and the full argv.
# Turn this on when a run does something you cannot explain, then paste the
# transcript. Off by default because argv includes the rendered prompt.
debug: false

# What child processes — agent steps, command steps and their checks —
# inherit from the daemon (T4.23). Resolved in one order: inherit, then
# unset, then set. Command steps layer the VINCENT_* variables and their own
# "env:" on top, so those are never affected.
#
# The default inherits everything, which is what the daemon always did
# implicitly. Pin it when a run has to be reproducible, or drop a single
# variable that breaks a CLI — on Windows, a daemon started from Git Bash
# carries MSYSTEM, which blocks every cursor tool call.
#
# Values under "set" are literal: "$" is not special and nothing is expanded.
environment:
  inherit: all          # all | none | a list of names, e.g. [PATH, HOME]
  # unset:
  #   - MSYSTEM
  # set:
  #   LANG: C.UTF-8

# Agent CLI locations. An empty path resolves the binary from PATH.
agents:
  claude:
    path: ""
  codex:
    path: ""
  # cursor resolves the "cursor-agent" binary — not "cursor", which is the
  # editor launcher (§9.7).
  cursor:
    path: ""

# How many verifications inside one "parallel" step run at once (§7.5).
# The step is done when its last lane is, and a lane that fails fails the
# step; this only bounds how many are in flight.
#
# parallel:
#   max_parallel: 4

# Bounds on "fan_out" (§7.6), which creates a task per item. max_depth is how
# many generations deep a tree may go — a lane that fans out again counts —
# and max_tasks is the total number of descendants one root may create.
# Passing either blocks the step rather than truncating the list.
#
# fan_out:
#   max_depth: 3
#   max_tasks: 64

# Ceiling on iterations of one "loop" step (§7.8). A loop that reaches it
# blocks with loop_limit, so a condition that never goes false is visible
# rather than endless.
#
# loop:
#   max_iterations: 10

# How many levels of "include" a workflow may nest (§7.9). Includes are
# spliced away when the task is created, so this bounds the splice, not the
# run.
#
# include:
#   max_depth: 5

# The MCP server the daemon serves at /mcp (§13.4), and the per-step endpoint
# an agent step is wired to. wire_steps: false stops the daemon handing agent
# steps their endpoint at all, which is how you take vincent's own tools away
# from a workflow without touching the workflow. max_depth and max_tasks bound
# the tasks an agent may create through those tools, the way fan_out's pair
# bounds a tree: a chain discovered at run time is only visible once it has
# spawned.
#
# mcp:
#   wire_steps: true
#   max_depth: 3
#   max_tasks: 32

# Reading GitHub issues, so a task can be created from one (task 035). It
# applies only to a project whose "origin" remote is a github.com repository,
# and the daemon makes no call at all until you open the issue picker or name
# an issue on the command line.
#
# vincent stores no credential: it drives the "gh" CLI when it is installed
# and authenticated, and otherwise reads GITHUB_TOKEN or GH_TOKEN out of the
# environment the daemon inherited. Set enabled to false to stop the daemon
# reading GitHub at all.
#
# poll_interval is how often the daemon reconciles the link between a task and
# its pull request, by matching an open pull request's head branch against the
# task's branch (task 052). It is one of vincent's two standing background
# calls — the other is the release check below — and it fires only for
# projects hosted on github.com: set it to 0 to switch the reconciler off and
# keep the rest of the integration, which then calls GitHub only when you ask
# it to.
github:
  enabled: true
  poll_interval: 5m

# Check whether a newer vincent has been released (task 055). The daemon asks
# GitHub for the project's latest stable release once a day, caches the answer
# in memory and serves it on GET /v1/update; "vincent doctor" and "vincent
# daemon status" show it. Prereleases are never reported.
#
# It is one unauthenticated GET. Nothing identifying is sent: no token, no
# telemetry, no machine or install identifier — vincent asks GitHub a public
# question and reads the answer.
#
# Unlike the GitHub reconciler above, this one fires for every install, which
# is why it has its own switch. With check: false the daemon makes no request
# for it at all; only running "vincent update" does, and that command makes
# its own call rather than going through the daemon. poll_interval: 0 has the
# same effect and keeps the key visible.
#
# Nothing is ever downloaded or installed by the check. "vincent update"
# applies one, and only when you run it.
update:
  check: true
  poll_interval: 24h

# Take the archive "vincent daemon backup" writes on a schedule (task 115).
# Off by default: interval is the one switch, and every archive carries all
# transcripts, which can run to gigabytes. Set it to at least 1h (24h is a
# sensible start); anything shorter is refused, because each run holds the
# database for the length of the copy.
#
# interval is counted from the newest scheduled archive already in dir, so a
# restart does not reset it and an overdue backup runs right after startup. A
# failed run is retried an hour later, and shows as a problem in
# "vincent doctor" until one succeeds.
#
# keep is how many scheduled archives survive each successful run; 0 keeps
# everything. Only files named vincent-backup-<UTC timestamp>.tar.gz count or
# are ever deleted: an archive you took by hand in the same directory is left
# alone.
#
# dir "" means <data dir>/backups. That is on the same disk as the database:
# it protects against corruption and mistakes, not against losing the disk.
# Point dir at another disk for that. An archive holds this file (including
# environment.set values and notify.command) and every transcript, so do not
# point it at a folder other people can read.
#
# backup:
#   interval: 24h
#   keep: 7
#   dir: ""

# Run a task's steps inside a container instead of on this host (task 061).
# "image" is the whole switch and "" is the default: no image, and every step
# runs here exactly as it always has. The image is yours — it must already
# carry the agent CLI your workflows use, and git. macOS and Linux only.
container:
  image: ""
  runtime: docker
  mount_agent_config: true
  network: true
  extra_mounts: []

# Tell someone when a task needs them, without a client attached (task 046).
# The daemon runs "command" whenever a task enters one of the states in "on",
# and writes a JSON envelope describing the transition to the command's stdin
# — task id and title, from/to, block_reason, project, workflow, step cursor,
# branch and worktree path — so a one-line script can write a message without
# calling back into the API.
#
# "command" is argv, not a shell line: nothing is expanded, quoted or split,
# and there is no shell on any platform. Both keys are needed; either alone
# loads and warns. Off by default.
#
# The states are the ones from §6, most usefully: blocked, awaiting_gate,
# awaiting_input, done. Only root tasks notify — a fan_out lane is a task row,
# and a twenty-lane tree would otherwise send twenty-one messages.
#
# It is fire-and-forget: at most 4 notifiers run at once, a child is killed
# after 10 seconds, failures are logged and never retried, and nothing is
# replayed for events that happened while the daemon was down.
#
# WARNING: this is arbitrary code the daemon runs as you, and its argv can
# carry a secret (a webhook URL), which is part of why this file is
# owner-only.
#
# notify:
#   on: [blocked, awaiting_gate, awaiting_input]
#   command: ["/usr/local/bin/notify-me"]

# What clients render, not what the daemon does. The daemon validates these,
# hot-reloads them and serves them on GET /v1/config; the TUI reads them from
# there.
#
# group_by nests the task table under headers, outermost level first. Accepted
# levels: project, workflow. Use [] for one flat list of tasks. A grouped
# level drops its own column — the header already names it — and "g" cycles
# the grouping for the session without touching this file.
#
# hyperlinks makes a Markdown link in the output pane clickable, as an OSC 8
# hyperlink, when its destination is a plain http or https URL. Off by default:
# nothing detects whether your terminal supports OSC 8, and one that does not
# may print the sequence. The destination stays printed under the message
# either way.
#
# keys rebinds the TUI's operations, one key each: an operation id such as
# refresh, approve, palette or quit, mapped to a key written the way the TUI
# names it (R, ctrl+e, f5). Unset, every operation keeps its shipped key. An
# override moves the operation on every screen that has it, and its old key
# stops working for it. A key that already means something else, an operation
# that is not rebindable, or a plain character on an operation that works
# while you type is refused, the way any invalid value in this file is.
tui:
  board:
    group_by: [project, workflow]
  hyperlinks: false
  # keys:
  #   refresh: ctrl+e
```

## Keys

### `listen`

```yaml
listen: 127.0.0.1:0
```

The address the HTTP API binds to. **Loopback only** — `127.0.0.1`, `::1` or
`localhost`; anything else is rejected at load. Port `0` picks an ephemeral port,
published for clients in `{data_dir}/daemon.json`. Pin a port if you want a
stable URL for scripts.

There is no TLS and no remote binding. The API is loopback plus a bearer token,
which is what keeps other local users and drive-by browser requests out; CORS is
disabled for the same reason.

Changes take effect on the next restart.

### `max_parallel_tasks`

```yaml
max_parallel_tasks: 3
```

Global cap on tasks in a slot-consuming state. Must be at least 1.

Only `running` and `awaiting_input` consume a slot. A task at a gate, blocked, or
paused does not — a human is the bottleneck there, and holding a slot would
starve everything else. `awaiting_input` **does** hold its slot, because the
agent process is alive mid-step and killing it would lose the session the answer
belongs to.

Real agents cost money; this is the knob that bounds it.

### `max_parallel_chats`

```yaml
max_parallel_chats: 3
```

Cap on **chats** holding a live agent process — a separate dimension from
`max_parallel_tasks`, which never sees a chat and is never delayed by one. Must
be at least 1. It is **not** in the generated default file above; omitting it
keeps the default, as omitting any key does.

A chat counts while it is `running` or `awaiting_input`, for the same reason a
task does: the process is alive. A `send` over the cap is refused immediately
with `409 chat_cap_reached` and is **never queued**. That is the point — a chat
is a foreground conversation, and parking your reply behind someone else's batch
work is the wait chats exist to avoid. You get an error you can act on rather
than a spinner.

The honest ceiling on concurrent agent CLIs is therefore
`max_parallel_tasks + max_parallel_chats`, which is 6 by default.

### `branch_template`

```yaml
branch_template: "vincent/{{.ID}}{{with .Slug}}-{{.}}{{end}}"
```

The naming convention for task branches. Empty (the default) means the built-in
`vincent/{id}-{slug}`, with `vincent/{id}` when a title sanitizes to nothing.

Names resolve through a chain, most specific first:

| Level | Set where |
|---|---|
| a pull request's head branch | the task was created from a pull request (`--github-pull` / `github_pull`). Nothing below overrides it |
| the task's own name | `--branch` / `branch_name`, used **verbatim**, never rendered |
| project template | [`vincent project edit --branch-template`](cli.md#vincent-project-edit), or `PATCH /v1/projects/{id}` → `branch_template` |
| global template | this key |
| built-in | nothing configured |

The top level is not configurable and not negotiable: a task created from a pull
request runs **on** that pull request's head branch, so a template or a typed
literal would put its commits somewhere the pull request never sees. See
[creating a task from a pull request](cli.md#creating-a-task-from-a-pull-request).

Available in a template:

| Reference | Meaning |
|---|---|
| `{{.ID}}` | The task id. Resolved after the row exists, so it is always unique |
| `{{.Title}}` | The title verbatim |
| `{{.Slug}}` | The title slugged the way the built-in name does it |
| `{{.BaseBranch}}` | What the task branches from |
| `{{.Fields.NAME}}` | A task field — **errors** when the task does not set it |
| `{{.Project.Name}}`, `.Path`, `.DefaultBranch` | The project |
| `{{ slug X }}` | Slug any value: `{{ slug (index .Fields "ticket") }}` |

Two things to get right, because git will not catch them for you:

**Put a discriminator in it.** Vincent never deletes a branch that carries a
commit, so a template like `feat/{{.Fields.ticket}}` collides on the *second* task
for the same ticket. Include `{{.ID}}`, or expect to name repeats by hand.
([`delete_empty_branch_on_archive`](#delete_empty_branch_on_archive) reclaims only
branches that received no commit, which is not the case this is about.)

**Prefer `{{.Fields.x}}` over `{{ index .Fields "x" }}`.** The first fails loudly when
the field is missing; the second renders *nothing*, giving you `feat/-fix-login` — a
perfectly legal branch name that is not what you meant. Use `index` only for a segment
you deliberately want optional, and wrap it:

```yaml
branch_template: 'feat/{{ with index .Fields "ticket" }}{{.}}-{{ end }}{{.Slug}}'
```

An invalid template fails at startup rather than silently reverting to the built-in
name. On hot-reload the previous value is kept and a warning logged, so one bad edit
does not also revert an unrelated change in the same save.

### `defaults`

```yaml
defaults:
  agent_timeout: 60m
  command_timeout: 15m
  input_timeout: 24h
```

Fallback timeouts used when a workflow step declares none. All are Go duration
strings (`45m`, `1h30m`, `90s`) and all must be positive.

| Key | Applies to |
|---|---|
| `agent_timeout` | One attempt of an agent step, **and one chat turn** |
| `command_timeout` | One attempt of a command step, and checks |
| `input_timeout` | Each wait in `awaiting_input` — measured **per request**, so a new question starts a fresh window — on a task **and on a chat** |

A timed-out process is killed and the attempt counts as a failure under the
normal retry policy. The step clock **pauses** while a task is
`awaiting_input`: it measures agent work, not human latency.

Workflow `defaults:` and per-step fields override these.

A [chat](cli.md#vincent-chat) turn gets `agent_timeout` and `input_timeout`
verbatim, with the same pause rule and **no override**: `defaults:` and per-step
fields are workflow things, and a chat has no workflow. An expiry fails the turn
with `timeout` or `input_timeout`, kills the process tree, returns the chat to
`idle` and releases its `max_parallel_chats` slot.

### `delete_empty_branch_on_archive`

```yaml
delete_empty_branch_on_archive: true
```

When a task is archived, delete its branch if that branch has **no commits past
the base it was cut from**. That is the branch a workflow which never writes to
the repository leaves behind — filing an issue, posting a summary, reviewing
read-only — one empty ref per run, with no reliable glob to find them again since
branch names are configurable.

The test is `git rev-list -n 1 {base}..{branch}` producing nothing: the branch tip
is an ancestor of the base. It stays correct when the base moves on after the task
started, and it is exact — **a branch carrying any commit is never deleted**.

Four refusals are deliberate, and all of them keep the branch:

| Situation | What happens |
|---|---|
| The branch has commits | Kept, reported `has_commits` |
| Vincent did not cut the branch — the task was created from a pull request | Kept, reported `not_ours` |
| git cannot judge it — base branch renamed or deleted, repository gone | Kept, reported `unknown`, logged |
| The delete itself fails — the branch is checked out in another worktree | Kept, reported `error`, logged |

`not_ours` is the one that is a *policy* rather than an accident. A task created
from a pull request runs on that pull request's head branch, and a task made from
a **merged** pull request is by definition "no commits past its base" — precisely
the case this key fires on. Deleting there would delete a contributor's branch, so
neither leg runs on such a task.

A branch problem never fails an archive: the task reaches `archived` either way,
and the branch simply survives, which is what always used to happen.
`POST /v1/tasks/{id}/archive` reports the outcome in its response and the TUI
prints it in one line; every other path logs to `daemon.log`.

The same rule runs for every task row `DELETE /v1/projects/{id}?force` is about to
delete, archived ones included — that cascade erases the branch names for good, so
it is the last moment they exist. `vincent gc` and `vincent doctor --fix` delete no
branch at all: an orphaned directory has no task row, so there is no base branch to
judge it against.

The "no commits past its base" check is measured against the commit the task
branch was actually cut from when `fetch_base_branch` recorded one, and against
the base branch by name otherwise. That distinction is what keeps this key
working once a task starts from a fetched remote tip: a task that wrote nothing
is still *ahead* of your local base branch, so measuring against the name would
answer "has commits" and quietly stop deleting anything.

Set it to `false` to keep every branch on every path, which is what vincent did
before this key existed. Read per archive, so a hot reload applies to the next one.

### `fetch_base_branch`

```yaml
fetch_base_branch: true
```

Refresh a task's base branch from its remote just before the task's worktree is
created, and start the task branch at the fetched commit. **On by default.**
Without it, every task builds on whatever your local base branch was the last
time *you* ran `git pull` — on a daemon left running for days over projects that
keep receiving merged pull requests, that is arbitrarily stale, and the agent
writes against code that has already moved.

Your local base branch moves too, when that loses nothing. After a successful
fetch, vincent fast-forwards `<base>` to the fetched commit — and, when it is
checked out (usually in your own checkout), that working tree with it, without
running any `post-merge` or `post-checkout` hook. The branch is left exactly
where it is when it has commits the remote does not (it is ahead, or has
diverged), or when its checkout has any change at all, untracked files included,
or is partway through a merge, rebase, cherry-pick, revert or bisect. A skipped
fast-forward never blocks the task, which starts from the fetched commit either
way; it shows on the task as a `base refresh` row in the TUI's detail view, a
`refresh` row in `vincent task show`, and `base_refresh` in the API.

The remote comes from the base branch's own configuration — `branch.<base>.remote`
and `branch.<base>.merge` — so a local `master` that tracks `main` upstream fetches
the right ref, and `origin` is never assumed. Anything without that pair simply
does not fetch:

| Situation | What happens |
|---|---|
| The repository has no remote | Created from the local base, logged at debug |
| The base branch has no upstream | Created from the local base, logged at debug |
| A `fan_out` lane, whose base is its parent's `vincent/…` branch | Created from the parent's branch, logged at debug |
| The remote is unreachable, refuses auth, or does not answer within 60 seconds | Created from the local base, logged as a **warning** and shown on the task |

A fetch never blocks a task and never introduces a block reason. Task creation
itself stays offline: `POST /v1/tasks` still rejects a `base_branch` that does not
exist locally, and the fetch happens later, when the scheduler first admits the
task.

Set it to `false` for a repository where fetching is slow or needs interactive
auth; that turns off the fetch and the fast-forward alike. It covers chats as
well as tasks. Read per worktree creation, so a hot reload applies to the next
task admitted or chat created. There is no per-project
override.

### `delete_remote_branch_on_archive`

```yaml
delete_remote_branch_on_archive: false
```

Also delete the upstream counterpart of a branch that qualified above. **Off by
default**, and unlike its local sibling it is honoured only when *you* archive a
task (`POST /v1/tasks/{id}/archive`) — deleting a branch on a forge you share with
other people cannot be undone, so no unattended path does it. `DELETE
/v1/projects/{id}?force` never touches a remote.

It runs only after the local delete succeeded — so a branch kept as `not_ours`
stops both legs, which is exactly the branch this key would otherwise delete on
the forge — and only when the branch has a configured upstream
(`branch.{name}.remote` and `branch.{name}.merge`). No
upstream means nothing was pushed as far as vincent knows, so nothing is
attempted — and the remote name is never guessed from the local one. The push is
`git push --delete {remote} {ref}` under a 60-second timeout; a rejection, an
unreachable host or a timeout is logged, reported in the archive response, and
never fails the archive.

It does nothing while `delete_empty_branch_on_archive` is `false`. That
combination still loads — a key that cannot do anything is not an invalid one —
but the daemon logs a warning at startup and on any reload that introduces it.

### `transcript_retention_days`

```yaml
transcript_retention_days: 90
```

Transcripts of **archived** tasks older than this are pruned, measured from the
archive time. `0` disables pruning. The pruner runs at daemon start and on a
24-hour ticker, which is what makes retention work on a daemon that survives
reboots rather than only on the restarts it no longer has.

The **pruner** deletes no rows — only the transcript files. Task and step rows
go when a human asks for it, and only then:
[`vincent task delete`](cli.md#vincent-task-delete) on an archived task, or
removing the project.

This covers [chats](cli.md#vincent-chat) too: an **ended** chat's turn
transcripts under `{data_dir}/transcripts/chat-{chat_id}/` are pruned on the
same pass, under the same setting, measured from when the chat ended. All three
endings count — `archived`, [`handed_off`](cli.md#vincent-chat-handoff) and
[`closed`](cli.md#vincent-chat-close) — and a handed-off or closed chat's
transcripts are its own rather than the worktree's, so pruning them never
reaches the task that owns it. A chat that has not ended
keeps its transcripts however old it is, exactly as a task does. Chat and turn
rows are the same: the pruner never touches them, and
[`vincent chat delete`](cli.md#vincent-chat-delete) is what removes one.

That same pass also drops
[idempotency keys](api.md#transport-and-auth) older than a fixed 24 hours. This
setting does not govern them: `0` keeps every transcript, and expired keys still
go.

### `transcript_max_bytes`

```yaml
transcript_max_bytes: 512MB
```

Per-attempt transcript cap. Written as a human size (`512MB`, `1GB`, `4096` for
bare bytes); suffixes are binary multiples.

Past the limit, the transcript latches, later writes are dropped, the process
tree is killed, and the step — or the [chat](cli.md#vincent-chat) turn, which
the same cap bounds — fails with `transcript_limit`. The tripping
annotation is written whole and bypasses the cap, so the file records *why* it
ends — a half-written line would turn a size failure into a parse failure for
every later reader.

`0` disables the cap.

**Chats are outside this too**: a chat turn's transcript is written with no cap,
so nothing latches it and no turn fails with `transcript_limit`.

### `max_task_cost_usd`

```yaml
max_task_cost_usd: 5.00
```

A ceiling, in US dollars, on what **one task** may spend. `0` — the default —
means no cap, so this changes nothing until you set it.

The figure it compares against is the task's cost so far: the sum of every
attempt of every step it has run, retries included — and repair runs and
follow-up runs, which are step runs of that task like any other. It is a
lifetime total and never resets. That is the same number the board and detail
views show. When it goes over, the task is `blocked` with `cost_limit` and
nothing further runs. A [chat opened on the task](task-lifecycle.md#chatting-with-a-stopped-task)
is not part of it: its turns' cost stays on the chat, so they do not count
toward this cap.

It is a **block, not a step failure**. The step run that finished keeps its own
state and its own reason — if it succeeded, it still reads `succeeded` — no
retry is consumed, and a retry that was already due does not happen.

**You will overshoot by up to one attempt.** The check runs after an attempt
finishes, because that is when an agent reports what it spent; there is no
running total to watch mid-run. An `agent_timeout` still bounds a single run.

**It counts one task.** Every lane of a `fan_out` step is its own task with its
own budget, so a cap of `$5.00` on a twenty-lane fan-out permits `$100` across
the tree. To bound the tree as a whole, set
[`max_tree_cost_usd`](#max_tree_cost_usd) as well.

**It is inert on codex and cursor.** Only claude reports cost. A task run
entirely on an agent that reports none is never blocked by this, whatever you
set — vincent will not estimate money from token counts. See
[Agents](../guides/agents.md).

**A task can carry its own cap as well**, set when it is created
([`max_task_cost_usd`](api.md#paused-restricted-and-capped-tasks) on
`POST /v1/tasks`, or `vincent task add --max-task-cost-usd`). The lower of the
two applies, and `0` on either side means no cap from that side, so a task's
cap can tighten this one but never lift it.

The remedy when a task blocks is to raise this value and press `retry`; the file
is hot-reloaded, so no restart is needed. Retrying *without* raising it makes
one more attempt and blocks again. A task's own cap is fixed at creation, so
when it is the lower of the two, raising this value does not move the wall. See
[Troubleshooting](../guides/troubleshooting.md).

Must not be negative.

### `max_tree_cost_usd`

```yaml
max_tree_cost_usd: 20.00
```

A ceiling, in US dollars, on what **one fan-out tree** may spend. `0` — the
default — means no cap, so this changes nothing until you set it.

A tree is a task that has no parent, plus every [`fan_out`](../guides/workflows.md)
lane below it at any depth, lanes of lanes included. A task that never fans out
is a tree of one, so the cap applies to it too. The figure is the sum of every
attempt of every step run by every task in the tree: retries, repair runs,
follow-up runs and the lanes a follow-up run spawns all count, and so do lanes
that have since been archived. It is a lifetime total and never resets.

When it goes over, the task whose attempt took the tree past the line is
`blocked` with `tree_cost_limit`. That is usually a lane, but it can be the
parent: its own steps before the fan-out and after the join, and an agent that
resolves a merge conflict, are attempts in the tree too. A parent waiting in
`awaiting_children` makes no attempts, so it does not block while it waits.
Its join stays open because a lane is blocked, and the parent's detail shows
which one. Everything else is what [`max_task_cost_usd`](#max_task_cost_usd)
does: a block rather than a step failure, the finished step run keeping its
own state and reason, no retry consumed, and a retry that was already due not
happening.

**You will overshoot by up to one attempt per task still working in the tree.**
The check runs after each attempt finishes, and each task checks at its own
boundary. When the total crosses, a lane that is running finishes the attempt it
is on, and a lane still queued makes one attempt when it starts. Each of them
blocks after that attempt. A wide tree can therefore pass the cap by several
attempts, not one.

**It is independent of `max_task_cost_usd`.** The two caps measure different
things and both are checked at the same point. When both are passed at once the
task blocks `cost_limit`, because raising this value would not clear that one.

**It only counts what was reported.** Only claude reports cost, so a tree run
entirely on codex or cursor is never blocked by this. A tree that mixes claude
with codex or cursor lanes counts claude's spend alone, and the rest is not
estimated from token counts. See [Agents](../guides/agents.md).

**It is set here and nowhere else.** No create-time field, `vincent task add`
flag, trigger `limits:` key or workflow field sets it. Change it with
`vincent config set max_tree_cost_usd`, `PATCH /v1/config`, or the daemon view's
config editor, where it reads `max tree cost` and shows `off` when it is `0`.

The remedy when a task blocks is to raise this value and press `retry` on the
**parent**: one retry re-admits every blocked lane beneath it. You can also retry
a single lane. The file is hot-reloaded, so no restart is needed. Retrying
*without* raising it buys each re-admitted lane one more attempt, then blocks it
again, so a parent retry costs one attempt per blocked lane every time you press
it. See [Troubleshooting](../guides/troubleshooting.md#tree_cost_limit--raise-the-tree-cap-and-retry-the-parent).

Must not be negative.

### `usage_limit_recheck_interval`

```yaml
usage_limit_recheck_interval: 15m
```

How long a task waits before vincent tries it again after its agent reported
that the account's usage quota for the current window is spent.

It applies only when the CLI named **no** reset time. When it names one, that
timestamp is used instead and this value is unused.

While it waits the task is `queued`, not `blocked`, and holds **no** concurrency
slot — everything else keeps running, and the task recovers on its own when the
window reopens. Nothing is retried in the meantime: a quota stop consumes none
of the step's retry budget. See
[Task lifecycle](task-lifecycle.md#failure-reasons).

Must be positive. There is no exponential backoff; if you know your plan's
window, set this to match it. Hot-reloaded, so a change applies to the next
task that hits a limit.

Whether the wait happens at all is
[`usage_limit_auto_continue`](#usage_limit_auto_continue) below. This value
keeps its meaning in every mode — it is the estimate vincent falls back to when
the CLI named nothing — even where it no longer times a wait.

### `usage_limit_auto_continue`

```yaml
usage_limit_auto_continue: always
```

Whether a recognized usage-limit stop is waited out or handed to you. One of
three values:

| value | a recognized quota stop … |
|---|---|
| `always` (default) | waits: the task returns to the queue, holds no slot, and is tried again when the window reopens |
| `reported_only` | waits when the CLI actually named a reset time; **blocks** when the wait would be the [`usage_limit_recheck_interval`](#usage_limit_recheck_interval) estimate |
| `never` | always blocks |

`always` is what every version before this key did, so leaving it alone changes
nothing.

When it blocks, the task goes `blocked` with `block_reason: usage_limit` at the
step it was on. The attempt is still recorded `interrupted`, still consumes
**none** of the step's retry budget, and the step is not advanced past — press
`retry` once the window reopens and it re-runs with a full budget. See
[Task lifecycle](task-lifecycle.md#block-reasons).

Turn it down when you would rather be told than waited for. vincent recognizes
a spent quota by the CLI's wording, and a stop it recognized *wrongly* would
otherwise re-queue itself and spend money on the next attempt, unattended.
`never` stops that at the first occurrence. `reported_only` is the middle
ground: it blocks exactly the stops where vincent is guessing at the window,
and keeps the unattended recovery for the ones where the CLI said when it
reopens.

The mode is read at the moment of the stop, so a hot reload reaches the next
one without a restart. A task **already waiting** keeps its wait: it is tried
once more, and meets the new mode at the next stop. The spent window still
reaches [`GET /v1/agents`](api.md) and the board's agent badge in every mode —
that is how you see why a task just blocked.

The mode also decides what happens to **every other task** headed for that
agent once a stop has been recorded. vincent checks the recorded window just
before it would start an agent process:

| value | a task reaching an agent step while the window is still shut … |
|---|---|
| `always` (default) | waits too, without starting the agent: it goes back to `queued` with `queued_reason: usage_limit` until the recorded reset, and no attempt is recorded |
| `reported_only` | waits too when the CLI named the reset; starts normally when the reset is vincent's estimate |
| `never` | starts normally, and finds the limit itself |

Only windows vincent watched close count. A quota an agent merely
[reports](../guides/agents.md#how-much-quota-is-left-and-who-will-say), even at
100%, never holds a task. When the reset passes, every waiting task becomes
eligible again under the normal concurrency caps.

Only claude recognizes a spent quota at all, so this key is inert on codex and
cursor. See [Agents](../guides/agents.md).

### `parallel`

```yaml
parallel:
  max_parallel: 4
```

How many sub-steps of a `type: parallel` step group run at once, when the group
does not set its own `max_parallel:`. Must be at least 1.

**This is a second concurrency dimension, and your task caps do not cover it.**
`max_parallel_tasks` counts *tasks* in a slot-holding state; a group runs
inside one such task, so one running task can keep four processes busy. A board
reading "1 running" is not a promise about the load on the machine — size this
for the hardware, not for the board.

Read when a group starts, so a reload governs the next group rather than
resizing one already running. See
[Workflow schema](workflow-schema.md#type-parallel).

### `fan_out`

```yaml
fan_out:
  max_depth: 3
  max_tasks: 64
```

Bounds on a `type: fan_out` tree, both checked when a task is created and both
reported as a `400` naming the bound crossed. `max_tasks` counts the child
tasks one creation would produce, **not** counting the root.

They are enforced at creation because the whole tree's shape is known there —
lane lists live in the task's snapshot — which is what turns a depth-3
explosion into an error in front of the person typing rather than two hundred
worktrees discovered six hours later.

**A step that derives its lanes moves `max_tasks` to spawn time.** A `fan_out`
with `for_each:` and a `lane:` template has no lane list to count at creation,
so it counts as one task there, and the real check runs when the step spawns:
past `max_tasks` for the whole tree, or past the step's own `max_lanes:`, and
the task **blocks** with `fan_out_limit` having spawned nothing — a block
rather than a `400`, because by then nobody is standing in front of it.
`max_depth` is unaffected either way: it counts nesting, and a dynamic width
does not nest.

Depth is unlimited by design and bounded by a default: a deeper tree is a
config edit, not a code change. See
[Workflow schema](workflow-schema.md#type-fan_out).

### `loop`

```yaml
loop:
  max_iterations: 10
```

The ceiling on a `type: loop` step's iterations, used when the step declares no
`max_iterations:` of its own. Must be at least 1.

It is a ceiling in two places. At **load** it is what `count:` is checked
against, so `count: 5000` is a validation error in front of the person typing
rather than a discovery on iteration 300. At **run time** it is what a
`for_each:` list is measured against: a list longer than the ceiling blocks the
task with `loop_limit` before the first iteration, naming the count. A loop
that hits the ceiling does not truncate and does not advance — running out of
tries is not a decision the workflow made.

The default is deliberately low. An agent step is minutes and dollars, and ten
iterations of a three-step body is already thirty agent runs. Raise it in
config for a whole machine, or per step with `max_iterations:` for the one loop
that needs more.

Read when a loop starts, so a reload governs the next loop — including one
already running, which will block with `loop_limit` if the lowered ceiling is
already behind it. See [Workflow schema](workflow-schema.md#type-loop).

### `include`

```yaml
include:
  max_depth: 5
```

How many levels of `type: include` one workflow may expand to. A workflow
including another is depth 1; that one's own include is depth 2. Must be at
least 1.

Checked when a task is created, which is where an include is resolved: the
callee's steps are spliced into the task's snapshot there and the registry is
never read again. Exceeding it is a `400` naming the depth and the bound.

There is no bound on the expanded *step count*, because step ids must be unique
across an expansion — a workflow reached twice is an error rather than a
doubling, so an expansion cannot multiply silently. Depth is the only dimension
left to bound, and a deeper chain is a config edit rather than a code change.

See [Workflow schema](workflow-schema.md#type-include).

### `log_level`

```yaml
log_level: info
```

One of `debug`, `info`, `warn`, `error`. The log lives at
`{data_dir}/logs/daemon.log`, is size-capped and rotated, and is read by
[`vincent daemon logs`](cli.md#vincent-daemon-logs) and the TUI's daemon view.

### `debug`

```yaml
debug: false
```

Records, in **every step's transcript**, the exact conditions the step ran under:
the resolved agent, model and effort, the permission mode, the working directory,
and the full argv of the process spawned.

Turn it on when a run does something you cannot explain — "the step asked for
restricted, did it get it?" is otherwise unanswerable without reading the stored
snapshot out of the database. Off by default because argv carries the rendered
prompt and transcripts are something people paste into issues.

### `environment`

```yaml
environment:
  inherit: all          # all (default) | none | a list of names
  unset: [MSYSTEM]      # dropped after inherit
  set:                  # literal values, last word
    LANG: C.UTF-8
```

What child processes inherit from the daemon — **agent steps, command steps
and their checks alike**. Resolved in one order: `inherit`, then `unset`, then
`set`.

Before this key existed nothing decided it. The detached daemon inherits the
shell that started it, so the same task, workflow and agent ran differently
depending on what launched the daemon, and nothing recorded which. The default
`inherit: all` is exactly that old behaviour, so this changes nothing until you
ask it to.

| Field | Meaning |
|---|---|
| `inherit` | `all` takes the daemon's whole environment; `none` takes nothing; a list takes only those names. An **empty list means nothing**, not everything |
| `unset` | Names dropped after inheriting. This is the "inherit all except" case |
| `set` | Literal values, applied last — so `set` wins over both `inherit` and `unset` |

**Values under `set` are literal.** `$` is not special and nothing is
expanded, so `PATH: "${PATH}:/opt/bin"` sets that string verbatim rather than
appending.

**`set` is not a secret store.** A literal value is exactly where an API token
would go, and nothing here encrypts one: it is plaintext in a file you may hand
to version control or sync between machines. Vincent creates `config.yaml`
`0600` inside a `0700` directory and re-tightens both on every daemon start on
POSIX — see [Files and directories](files.md#the-config-directory) — but that
only keeps other local accounts out. Prefer naming the variable under `inherit`
and letting the value come from the environment that starts the daemon; see
[Security model](../security-model.md#credentials).

Agent, command and check steps layer the [`VINCENT_*` variables](../guides/workflows.md#55-environment-variables)
on top of that, and command and check steps then layer their own `env:`. Neither
`unset` nor `set` can touch the `VINCENT_*` block — those are facts about the
run, not inherited state — and a step's `env:` still wins over everything.

#### What it is for

The case that motivated it: on Windows, a daemon started from Git Bash carries
`MSYSTEM`, and Cursor imports Claude Code's hooks and evaluates them under
bash, so **every** cursor tool call is silently blocked. No amount of
`unset MSYSTEM` in the launching shell helps — the MSYS runtime re-injects it
into every child. Vincent sets the child's environment block directly, so:

```yaml
environment:
  unset: [MSYSTEM]
```

genuinely removes it.

More generally: pin the environment when a run has to be reproducible, and
`inherit: none` plus an explicit `set` when it has to be hermetic.

#### Two things to know

**Vincent honours the policy as written and warns rather than correcting it.**
If the resolved environment has no `PATH` (or no `SystemRoot` on Windows) the
daemon logs a warning at startup and runs anyway. The failure it is warning
about is silent and late: adapters are resolved with `exec.LookPath` inside the
daemon and started by absolute path, so an agent with no `PATH` starts
perfectly and then fails several steps in, when the CLI shells out to git.

**The resolved variable names are logged, and never the values.** At startup
and again whenever a hot-reload changes the set:

```
level=INFO msg="child environment resolved" inherit=all unset=1 set=0 count=42
  names="APPDATA,HOME,LANG,PATH,USERPROFILE,…"
```

Values are never written to the log at any level — an environment block is
where every agent CLI's credentials live, and a log is something people paste
into issues. For the same reason the resolved environment is **not** recorded
in step transcripts, even under `debug: true`.

### `agents`

```yaml
agents:
  claude: { path: "" }
  codex:  { path: "/usr/local/bin/codex" }
  cursor: { path: "" }
```

Where to find each adapter's CLI. An empty path resolves the binary from `PATH`;
an explicit path is used as given and **never consults `PATH`**, which makes it
the standing fix for a CLI the daemon cannot see.

`cursor` resolves the **`cursor-agent`** binary, never `cursor` — that one is the
editor launcher and would open a GUI.

### `github`

```yaml
github:
  enabled: true
  poll_interval: 5m
```

Whether the daemon may talk to GitHub — read a project's issues and pull
requests, so a task can be created from an issue and linked to the pull request
opened from its branch, and open a pull request for a task when you ask it to.
It is an **opt-out**: on by default, and it costs nothing until you use it.

It governs **reading, plus the six writes below**. Nothing else under this key
writes to GitHub, and no GitHub call happens while a step runs — the daemon
calls when you open the issue picker, when it creates a task from an issue, when
a client asks for a pull request, when you ask it to open one or act on one, and
on the reconciler's tick. The issue is stored on the task at that
moment and never re-read, which is why a step's `.Issue` renders offline and
cannot fail because GitHub is down. A pull request is the opposite: only the
*link* is stored, and its title, state, draft and merged status are re-read
every time, because a snapshot of them would go stale and lie.

`poll_interval` is how often the daemon reconciles those links: it lists each
GitHub-based project's open pull requests and links the ones whose head branch
equals a task's branch. It is one of vincent's **two** standing outbound calls
— the other is [`update`](#update)'s release check — and it is the one that
fires only for projects hosted on github.com, so a daemon with no GitHub-origin
project makes no call under this key at all. That is why it has its own key —
set it to `0` and the reconciler stops while the rest of the integration keeps
working on demand. A link a human
made by hand is never overwritten by it, and a link a human removed is never
re-applied.

It applies only to a project whose `origin` remote parses as a github.com
repository. On every other project it does nothing at all — the issue row is not
offered, and no `gh` process is started.

Setting it to `false` stops the daemon talking to GitHub entirely: the TUI's
issue row disappears, `GET /v1/projects/{id}/github` reports `disabled`, the
pull request listing answers `409`, the reconciler stops, creating a task with
`--github-issue` is refused, and **every write to GitHub is refused too**. Read
per use, so a [reload](#reload-semantics) governs the next call and the next
tick.

**This key is also the only gate on everything vincent writes to GitHub.**
`POST /v1/tasks/{id}/github/pull/create` — `P` in the TUI, or
`vincent github pr create` — pushes a task's branch to `origin` and opens its
pull request, and five more routes, driven by
[`vincent github pr merge`](cli.md#vincent-github-pr-merge), `close`, `reopen`,
`comment` and `rerun`, act on a task's linked pull request. There is
deliberately no second switch for any of them: the consent is a person asking,
not a line in this file nobody would turn on, so `enabled: false` turns them off
along with every read. An agent cannot reach them, since none of those routes is
an MCP tool.

**There is deliberately no token key here.** Vincent stores no credential of its
own. It uses the `gh` CLI when that is installed and logged in — which carries
your own host, enterprise and SSO configuration — and otherwise reads
`GITHUB_TOKEN` or `GH_TOKEN` from the environment the daemon inherited. Both are
described under [environment variables](#environment-variables), and
`vincent doctor` reports which one is in play.

### `update`

```yaml
update:
  check: true
  poll_interval: 24h
```

Whether the daemon checks for a newer vincent release. It is an **opt-out**: on
by default, once a day.

The check is **one unauthenticated GET** of the project's latest-release feed.
Nothing identifying is sent — no token, no telemetry, no machine or install
identifier. Prereleases are never reported. The answer is cached in memory (a
restart re-polls, and nothing here is written to the database) and served on
[`GET /v1/update`](api.md#get-v1update); `vincent doctor` and `vincent daemon
status` render it.

Unlike [`github.poll_interval`](#github), which fires only for projects hosted
on github.com, this one fires for **every** install — which is why it has its
own switch. With `check: false` the daemon makes no request for it at all, and
only running [`vincent update`](cli.md#vincent-update) does; that command makes
its own call rather than going through the daemon, which is also why it works
before the first poll and with no daemon running.

`poll_interval: 0` has the same effect and keeps the key visible. A negative
interval is refused rather than rounded to zero. Read per tick, so a
[reload](#reload-semantics) governs the next one, including a reload that
switches the check back on.

**Nothing is ever downloaded or installed by the check.** Applying an update is
[`vincent update`](cli.md#vincent-update), which you run.

### `backup`

```yaml
backup:
  interval: 0
  keep: 7
  dir: ""
```

Scheduled daemon backups: the same archive
[`vincent daemon backup`](cli.md#vincent-daemon-backup) writes, taken by the
daemon on a timer. **Off by default.** Every archive carries every transcript,
which can run to gigabytes, so the daemon does not start spending disk on it
until you ask.

**`interval`** is the only switch. `0` is off. Any other value must be at least
`1h`, and `24h` is a sensible start. A negative interval, or one under an hour,
is refused: each run holds the database's only connection for the length of the
copy and then re-reads every transcript, and anything more frequent is a job for
cron or Task Scheduler. The interval is counted from the **newest scheduled
archive already in `dir`**, not from when the daemon started. A restart does not
reset the clock, and a backup that is overdue — or a directory with no archive
yet — runs at the first check after the daemon starts. A failed run is retried
an hour later, and until one succeeds it is a problem in
[`vincent doctor`](cli.md#vincent-doctor), which then exits `1`. An overdue
backup alone is not a problem.

**`keep`** is how many scheduled archives survive each successful run. Older
ones are deleted, and `0` keeps everything. A failed run deletes nothing. Only
files named `vincent-backup-<UTC timestamp>.tar.gz` (for example
`vincent-backup-20260917T030000Z.tar.gz`) count toward `keep` or are ever
deleted. An archive you took by hand in the same directory, and any other file,
is left alone. A negative `keep` is refused.

**`dir`** is where archives are written. `""` means `{data_dir}/backups`,
created owner-only on first use; any other value must be an **absolute path**.
Archives are written `0600`. Each one is written under a temporary
`.vincent-backup-*` directory in `dir` and moved to its final name only when it
is complete, so a daemon stopped mid-run never leaves a half-written archive
that looks like a good one. A `dir` inside `{data_dir}/transcripts` or
`{config_dir}/workflows` fails every run, because the archive would include
itself.

> **The default location is on the same disk as the database.** It protects
> against corruption and against mistakes, not against losing the disk. To
> survive that, point `dir` at another disk.

An archive holds this file, including
[`environment.set`](#environment) values and
[`notify.command`](#notify), and every transcript. Do not point `dir` at a
folder other people can read, or at a synced folder you would not put those in.
The TUI's config editor asks before it changes `dir`.

All three keys are read on every check, about once a minute, so a
[reload](#reload-semantics) takes effect at the next check with no restart.
`vincent doctor`'s `BACKUP` group and the TUI daemon view show the last
success, the next run and the last error; see
[Files](files.md#scheduled-backups) for restoring from one.
### `mcp`

```yaml
mcp:
  wire_steps: true
  max_depth: 3
  max_tasks: 32
```

The daemon serves the [Model Context Protocol](../guides/mcp.md) at
`http://127.0.0.1:{port}/mcp`, on the same listener as the REST API and behind
the same bearer token. **There is no `enabled` key**, deliberately: the endpoint
is part of the API surface the way `/v1` is, so "serving MCP" is not a mode the
daemon is in.

**`wire_steps`** decides whether vincent registers that endpoint with the agent
CLI it spawns for an [agent step](../guides/workflows.md), so a step's agent
gets the vincent tools with no configuration from you. **On by default**, an
opt-out. Set it to `false` and no adapter is given an MCP server: no
`--mcp-config` for claude, no `mcp_servers.*` overrides for codex, and no
`.cursor/mcp.json` written into the task worktree.

**`max_depth`** and **`max_tasks`** bound recursion. A step's agent can create a
task whose step runs an agent that creates a task, and unlike `fan_out.max_depth`
or `include.max_depth` that shape is not in any snapshot to check — it is
discovered one task at a time. `max_depth` is how many levels that chain may
reach; `max_tasks` is how many tasks one chain may hold in total. Both are
enforced when the task is created, and the refusal names the key it hit. Both
must be at least 1; a smaller value fails the load.

An adapter that cannot carry an MCP server fails the step with
`mcp_unsupported` rather than running an agent that silently has no vincent
tools. If that is not what you want, turn `wire_steps` off.

### `container`

```yaml
container:
  image: ""
  runtime: docker
  mount_agent_config: true
  network: true
  extra_mounts: []
```

Run a task's step processes inside **one container** instead of on this host.
**`image` is the whole switch.** Empty — the default — means no runtime is
consulted and every step runs here, byte for byte what it did before this key
existed. Name an image and the task's steps run inside a container created with
the task's worktree and removed with it.

**Which steps.** Every `agent` step, every `command` step, and every `check:`.
A `manual` step runs no process, so there is nothing to contain. An agent step's
transcript, token and cost records and exit code are the same as a host run's,
and a timeout or cancel stops the agent while the container survives for the
next step. [Chats](cli.md#vincent-chat) are not tasks and keep running on the
host — except a chat [opened on a containerized task](task-lifecycle.md#chatting-with-a-stopped-task),
whose turns run inside that task's container, because the worktree it works in
is one you chose to confine. If that container is gone, the turn fails rather
than running on the host.

**The image is yours.** It must already carry the agent CLI your workflows'
agent steps resolve to, and `git`. Vincent builds no image, publishes none and
bundles none — the same posture it takes toward `gh` and `cosign`. The CLI is
looked up by its plain name (`claude`, `codex`, `cursor-agent`) on the
**image's** `PATH`: [`agents.*.path`](#agents) is a host path and is ignored
inside the container, and the host does not need the CLI installed at all. An
image without it fails the step `agent_unavailable`, the same as a missing CLI
on the host.

Two mounts are made for you and need no `extra_mounts` entry: the project
repository and the task's worktree, each **at its own absolute host path**. That
is deliberate — a worktree's `.git` file holds an absolute `gitdir:` into the
parent repository, so mounting both where they already live makes git work
inside the container with no translation, and
[`.Worktree`](workflow-schema.md) and `VINCENT_WORKTREE` name the same
directory on both sides. It is also why **a Windows daemon refuses a
containerized task**: `C:\...` cannot exist inside a Linux container.

One consequence is worth knowing before you hit it: "the same absolute path"
means the **physical** one. A project path that runs through a symlink — the
common case being macOS's `/tmp` and `/var`, which are symlinks into
`/private` — is mounted as written while git resolves the worktree's pointer to
the physical path, and the step then reports `not a git repository`. Register
such a project by its resolved path (`cd project && pwd -P`).

**`runtime`** is a docker-CLI-compatible binary. Only **`docker`** is verified in
CI; `podman` and `nerdctl` are accepted because they take the same argv, which
is not the same claim as tested.

**`mount_agent_config`** bind-mounts `~/.claude`, `~/.codex` and `~/.cursor`
into the container **read-write**, and is **on** by default. The agent CLI runs
in the container and needs them: subscription-based auth takes no key from the
environment, and cursor persists `--model` to its own config. It also puts your
agent credentials within reach of the image and of every containerized step —
see [the security model](../security-model.md). Turn it off and an agent CLI
that then cannot authenticate is the expected result. A `config.yaml` that
already sets the key keeps its value.

With it on, the directories are mounted under a **vincent home**: the container
gets a writable, empty `/vincent-home`, each of the three directories that
exists on your machine is mounted beneath it (`/vincent-home/.claude` and so
on), and every containerized step — agent and command alike — runs with
`HOME=/vincent-home`. A directory you do not have is skipped rather than
created. Two consequences:

- **The image's own HOME is hidden** while the mounts are on. Anything your image
  put in its user's home directory is not where the step looks. If you need it,
  set `HOME` yourself: a [`environment`](#environment) policy that sets `HOME`,
  lists it in `inherit` or unsets it is left alone.
- **On macOS, claude's login does not carry over.** Claude Code keeps its OAuth
  login in the macOS Keychain, not in `~/.claude`, and the container cannot
  reach the Keychain. A Mac host must give a containerized claude step
  `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` through
  [`environment`](#environment). A containerized step reads `inherit: all` as
  `none`, so the variable has to be named in `inherit` or given under `set`.
  Codex's `auth.json` and claude's `.credentials.json`
  on Linux are files in the mounted directories and do carry over.

**`network`** keeps outbound traffic on, which is the default. `false` drops the
container off the network entirely. A container with no network cannot reach
the daemon's per-step MCP endpoint, so `network: false` with
`mcp.wire_steps: true` is refused at task creation **for a workflow that has an
agent step** anywhere — at the top level, inside `parallel`, `fan_out` or
`loop`, or spliced in by an `include`. A workflow of command
steps and checks only wires nothing and runs with no network. To run agents
offline, set `mcp.wire_steps: false`.

With the network on and `wire_steps` on, a containerized agent step reaches its
[MCP endpoint](../guides/mcp.md#your-own-steps-get-this-too) at
`host.docker.internal`. On Linux with Docker Engine, the daemon opens a second
listener on the container network's gateway address for that — it answers the
per-step endpoint and nothing else. Docker Desktop forwards
`host.docker.internal` to the daemon's own loopback port instead.

**`extra_mounts`** are additional bind mounts, each `host:container` or
`host:container:ro`. Both paths must start with `/`, on every platform — a
relative source would resolve against the daemon's working directory, and a
`C:\...` source is refused even on Windows, where the containerized task that
would have used it is itself refused at creation. That keeps one `config.yaml`
loadable everywhere: a Windows machine reads a Linux daemon's mounts without
complaining about a key it will never act on.

A workflow may override any of these in its `defaults.container:` block, which
beats this one per field. There is no per-task override.

**What is refused, and when:**

| Condition | When | What you see |
|---|---|---|
| The daemon runs on Windows | task creation | `400 validation_failed` |
| `runtime` missing or not usable | task creation | `400 validation_failed` naming the binary |
| `network: false` with `mcp.wire_steps: true`, and the workflow has an agent step | task creation | `400 validation_failed` naming the workflow |
| A step pins `shell: pwsh` or `shell: cmd` | workflow load, or task creation | a validation error naming the step |
| The image is missing and cannot be pulled | when the task is admitted | the task blocks `container_image_unavailable` |
| The runtime went away after creation | when the task is admitted | the task blocks `container_unavailable` |

The image check waits until admission on purpose: pulling a multi-gigabyte image
inside `POST /v1/tasks` would run it against the API's request timeouts, and
checking only what is already on disk would refuse every first run on a fresh
machine. Blocking at admission still costs you no worktree, no branch and no
retry. A step the container was going to run is **never** quietly moved to the
host when the runtime or the image fails — the task blocks instead. That
includes agent steps.

Run `vincent doctor` to see whether the runtime answers on this machine — it
probes even when `image` is empty, because "would this work if I turned it on"
is the question worth asking.

### `notify`

```yaml
notify:
  on: [blocked, awaiting_gate, awaiting_input, done]
  command: ["/usr/local/bin/notify-me"]
```

Run a command when a task enters one of these states. **Off by default** — both
keys are empty, and nothing is spawned.

It exists because vincent's premise is that you start work and walk away, and
the daemon is designed to run with no client attached. Without this, the only
alert in the whole system is the TUI's terminal bell, which rings on
`awaiting_input` and only while a board is open: a task could wait a full day
for an answer, fail on the timeout, and the first you knew was the next time you
looked.

**`on`** is a list of [task states](task-lifecycle.md). Any of the ten is
accepted; the four above are the ones worth waking up for. A name that is not a
state **fails the load** and names the value, so the daemon keeps its last good
configuration rather than silently never firing.

**`command`** is **argv, not a shell line**. The first element is the program;
the rest are passed through unchanged. Nothing is expanded, split, quoted or
interpreted, and there is no shell on any platform — `command: ["notify.sh
--urgent"]` looks for a program with a space in its name. Both keys are needed:
either one alone loads, warns in the log, and never fires.

#### The envelope

The daemon writes one JSON object to the command's standard input and closes it.
It is enriched on purpose: a notifier told only `{task_id, to}` would have to
call back into the API with a bearer token to say anything useful, which defeats
a one-line script.

```json
{
  "event_id": 1841,
  "ts": "2026-08-28T09:30:00Z",
  "type": "task.state_changed",
  "task_id": 42,
  "title": "Fix the flaky gate",
  "from": "running",
  "to": "blocked",
  "block_reason": "step_failed",
  "queued_reason": "",
  "admit_not_before": null,
  "current_step": 2,
  "steps_total": 5,
  "worktree_path": "/home/you/.local/share/vincent/worktrees/42",
  "branch": "vincent/42-fix-the-flaky-gate",
  "project_id": 7,
  "project": "vincent",
  "workflow": "review"
}
```

`block_reason` is empty unless `to` is `blocked`. `admit_not_before` is when a
queued task will next be tried, the *until* beside `queued_reason`'s *why*, as
RFC3339; it is `null` when the task is not waiting on a clock. A transition into
`awaiting_input` additionally carries the agent's question:

```json
  "input": { "kind": "question", "summary": "Which migration should I keep?" }
```

`steps_total` comes from the task's own workflow snapshot, so it is the count
for *that run* even if the workflow file has been edited since.

A one-liner that turns it into a desktop notification on macOS:

```sh
#!/bin/sh
jq -r '"\(.title) → \(.to)"' | xargs -0 terminal-notifier -title vincent -message
```

#### What it does and does not promise

| | |
|---|---|
| **Root tasks only** | A `fan_out` lane is a task of its own, so a twenty-lane tree would send twenty-one messages. Lanes are skipped; the parent's own transitions fire. |
| **At most 4 at once** | Drained from a 64-entry queue. Five tasks blocking together all notify; only a genuinely full queue drops, and a drop is logged. |
| **10 s per command** | Fixed, not configurable. Past it the command's whole process tree is killed and the failure is logged. |
| **Never retried** | A failure is logged with the exit code and the tail of its stderr, and dropped. |
| **Never replayed** | Only transitions the running daemon observes fire. A weekend of downtime does not produce a storm on the next start. |
| **Never blocks a task** | A notifier that hangs, fails or is missing changes nothing about the task it was about. |

The environment the command inherits is the one
[`environment`](#environment) resolves, like every other process the daemon
spawns. It gets **no** `VINCENT_*` variables — those belong to command steps —
because the envelope on stdin is this hook's whole contract.

Read per event, so a [reload](#reload-semantics) governs the next transition. It
**is** served on `GET /v1/config`, argv included: that endpoint is loopback-only
behind the same owner-only token as the file, so a client that can call it can
already read `config.yaml`. The one exception is the
[MCP](../guides/mcp.md) rendering of the same route, where the argv is masked —
a tool result lands in an agent's context and its transcript, which is not that
boundary. `command` can reasonably hold a webhook URL with a token in it; see
the [security model](../security-model.md) for what running it means.

### `triggers`

```yaml
triggers:
  enabled: true
```

The global switch for [event triggers](../guides/triggers.md): the files under
`{config_dir}/triggers/` that start or act on tasks when a command, GitHub or a
signed push reports an event, or when a schedule comes due. **Off by default.**

A trigger is off twice. A file does nothing until its own `enabled: true`
**and** this key are both on. The second key exists because a trigger has no
keypress to be the consent: with both on, someone labelling an issue or a CI job
going red causes agents to run as you.

While it is off, no trigger polls or ticks and `POST /v1/triggers/{id}/events`
answers `409`. The dry runs still work, because they fire nothing. Turning it on
arms every enabled trigger with a **fresh seed**: the first poll records what the
source already shows and fires nothing — a schedule anchors its clock instead —
so a backlog that existed before you turned it on never starts work.

Read per poll and per pushed event, so a [reload](#reload-semantics) reaches the
next one. `PATCH /v1/config` can set it, but the route is not an MCP tool, so an
agent cannot. The TUI's config editor asks before saving it. See the
[security model](../security-model.md#event-triggers-let-someone-else-start-an-agent)
before you do.

### `tui`

```yaml
tui:
  board:
    group_by: [project, workflow]
  hyperlinks: false
  keys: {}
```

The one section the daemon does not act on. It validates it, hot-reloads it with
everything else and serves it on `GET /v1/config`; the TUI reads it from there.
It lives here rather than in a file of the TUI's own because the TUI is a pure
API client — it reads no configuration from disk, and a second file would be a
second path, a second reload story and a second `vincent doctor` line for one
setting.

#### `tui.board.group_by`

How the task table is grouped, outermost level first.

| Value | Board |
|---|---|
| `[project, workflow]` (default) | Projects, and the workflows of a project nested inside it |
| `[project]` | One group per project |
| `[workflow]` | One group per workflow, across every project |
| `[]` | One flat list — the table every version before this one rendered |

Accepted levels are `project` and `workflow`. An unknown level, a repeated one,
or anything that is not a list fails the load and names the key. There is no
`state` level on purpose: the board already orders by state and pins everything
waiting on a human to the top, and grouping by it would fight that.

Grouping never reorders tasks. They are sorted exactly as before and each group
takes the position of its first task, so the group holding the oldest thing
waiting on a human is the first group. Group headers carry the task count and,
when the group holds any, the needs-attention badge and count.

A grouped level costs no column — the header names it, so `PROJECT` and
`WORKFLOW` drop out and the width goes to the title.

**`g` cycles the grouping for the session** — project›workflow → project →
workflow → flat — and never writes to this file. The Tasks panel title names the
grouping whenever it is not the one configured here.

#### `tui.hyperlinks`

Whether a Markdown link in the output pane is clickable. Default `false`.

With it on, a link in assistant prose — `[label](url)` or `![alt](src)`, in the
task workspace and in a chat — becomes an [OSC 8](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)
terminal hyperlink in three places: the label, its dim `[1]`, and the
destination on the `[1] https://…` reference line under the message. The
reference line is still printed, so what a click opens is always on screen as
text. Nothing else is linked: bare URLs, autolinks and the GitHub links vincent
shows itself stay as they are, raw mode (`ctrl+o`) shows source, and a copied
message carries no escape sequence.

Only a destination that passes a strict check is linked. It has to be an `http`
or `https` URL with a host, no `user@` part, at most 2048 bytes, and printable
ASCII only — no spaces, no control characters, no non-ASCII characters (so a
look-alike Unicode host is refused, not encoded). Anything else renders exactly
as it does with the setting off: label, `[1]`, printed destination, no link.

It is off by default because nothing detects whether your terminal supports
OSC 8. iTerm2, WezTerm, kitty, GNOME Terminal and Windows Terminal do; a
terminal that does not, such as the legacy Windows console, may print the
sequence instead. Turn it on only in a terminal you know handles it. Saving it
in the TUI's config editor applies it to both workspaces at once, with no
restart. A change made anywhere else — `vincent config set tui.hyperlinks true`
or an edit to this file — reaches a running TUI the next time it reads the
config: when you open the daemon view or press `R` there, or when it
reconnects. See the
[security model](../security-model.md#what-an-agent-writes-cannot-drive-your-terminal)
for why the destination is sanitized.

#### `tui.keys`

Rebinds the TUI's keys. A map of operation id to one key; the default, `{}`, is
the shipped keymap that the [TUI guide](../guides/tui.md) tables list.

```yaml
tui:
  keys:
    refresh: f5
    pause: x      # pause and reject swap keys
    reject: p
```

A key is written the way the TUI names it: a single character (`R`, `/`), a
named key (`enter`, `tab`, `esc`, `space`, `backspace`, `delete`, `insert`,
`home`, `end`, `pgup`, `pgdown`, `up`, `down`, `left`, `right`, `f1`–`f20`), or
either one behind `ctrl+`, `alt+` and `shift+`, in that order. A shifted
character is written as itself: `R`, never `shift+r`.

The operations are the ones a reader names, and nothing else:

| Kind | Operation ids |
|---|---|
| Vocabulary terms | `refresh`, `archive`, `delete`, `draft_remove`, `add`, `editor`, `free_text`, `browser`, `open_row`, `scope`, `filter`, `lane` |
| Task actions | `pause` (pause and resume), `approve`, `reject`, `retry`, `edit_retry`, `repair`, `skip`, `cancel`, `follow_up`, `chat`; `archive` is the term's id |
| Global | `palette`, `palette_alt`, `help`, `help_alt`, `next_attention`, `mouse`, `quit`, `new` (also a new chat on the chats board) |

An override moves its operation on **every** screen that has it, and it
**replaces** the default rather than adding a second key: the old key stops
working for that operation and is free for another override in the same map,
so two operations can swap. Mapping an operation to its own default changes
nothing. The footer, the palette, `?` and every hint that names a key show the
key in force.

The daemon refuses, naming the operation, the key and what the key already
means:

- an unknown operation id, listing the ones that exist;
- a key that is not rebindable — the grouping and lane-board keys, the fold,
  page and move sets, `esc`, `ctrl+c`, `ctrl+v`, `tab`, the confirmations' `y`
  and `n`, the vim aliases — by the name you might try for it (`group`, `fold`,
  `esc`, `resume`, …), with the reason;
- a string no key press produces, `shift+r` included;
- a key that already means anything else, anywhere in the TUI, even on a screen
  never open beside the operation. The shipped keymap's few shared keys (`R` is
  refresh and repair, `a` is add and approve, `s` is scope and skip) are
  recorded for those keys only: `refresh: r` is refused, because `r` is retry;
- a plain character or `space` for `palette_alt` or `help_alt`, whose purpose
  is to work while a text field has the keyboard.

Problems are reported under a `tui.keys:` prefix, in two passes: every unknown
id, fixed name and malformed key at once, and only when there are none, every
key that already means something else and every plain character for
`palette_alt` or `help_alt` at once. As with any
invalid value, a refused load does not start the daemon, a refused reload keeps
the last good configuration, and a refused `PATCH /v1/config` or
`vincent config set tui.keys "refresh=f5 pause=x reject=p"` leaves the file
byte-identical. That command, like the config editor's row, replaces the whole
map; `""` restores the shipped keymap.

Saving it in the TUI's config editor applies it at once. A change made anywhere
else reaches a running TUI the next time it reads the config: when you open the
daemon view or refresh there, or when it reconnects.

## Per-project settings

These live in the database, not in `config.yaml`, because they belong to a
registered repository rather than to the daemon. Set them at registration, edit
them with [`vincent project edit`](cli.md#vincent-project-edit), in the TUI's
projects view, or with `PATCH /v1/projects/{id}`:

| Setting | Meaning |
|---|---|
| `name` | Display name (defaults to the directory name) |
| `path` | The repository root; can be re-pointed if the repo moves |
| `default_branch` | What new tasks branch from |
| `default_workflow` | Used when a task names none |
| `max_parallel_tasks` | Per-project cap, applied on top of the global one |
| `branch_template` | This repository's branch convention; overrides the global [`branch_template`](#branch_template). Set it to `null` or `""` to inherit the global one again |

```sh
vincent project add /path/to/repo --name api --default-branch develop \
  --workflow feature-pr --max-parallel 2
vincent project edit 1 --branch-template 'api/{{.Slug}}'
```

## Environment variables

| Variable | Effect |
|---|---|
| `VINCENT_CONFIG_DIR` | Override the config directory outright |
| `VINCENT_DATA_DIR` | Override the data directory outright |
| `XDG_CONFIG_HOME` / `XDG_DATA_HOME` | Honored on Linux in the normal way |
| `EDITOR` | Used by the TUI for edit-and-retry, repair prompts, and description editing |
| `GITHUB_TOKEN` / `GH_TOKEN` | Read by the [`github`](#github) integration when `gh` is absent or logged out. The daemon inherits whatever the process that started it had; vincent never stores a token of its own, and never reports its value — `vincent doctor` names the variable only |

The two `VINCENT_*` overrides are how the test suite isolates state, and they
are equally useful for running a second, throwaway instance:

```sh
VINCENT_CONFIG_DIR=/tmp/v-cfg VINCENT_DATA_DIR=/tmp/v-data vincent daemon start
```

> **They do not reach a service by themselves.** A service does not inherit the
> shell that installed it, so `vincent service install` **captures** the
> directories in effect and writes them into the unit. Change them and reinstall,
> or the CLI and the service will quietly use different databases. See
> [Running at login](../guides/running-at-login.md).

Agent, command and check steps additionally receive `VINCENT_TASK_ID`,
`VINCENT_TASK_TITLE`, `VINCENT_PROJECT_NAME`, `VINCENT_PROJECT_PATH`,
`VINCENT_WORKTREE`, `VINCENT_BRANCH`, `VINCENT_BASE_BRANCH`, `VINCENT_STEP_ID`,
`VINCENT_STEP_ATTEMPT` and `VINCENT_WORKFLOW` — see
[Writing workflows](../guides/workflows.md#55-environment-variables).

## Reading and changing the config in effect

The API serves every key on this page, which is the reliable way to see what the
daemon actually loaded after a reload:

```sh
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:$PORT/v1/config | jq
```

`PATCH /v1/config` changes it. The daemon validates the whole candidate file,
writes it and puts it into force before answering, so the next read shows the
new value and nothing needs restarting — except [`listen`](#listen), which is
written to the file while the running daemon keeps the address it bound. An
invalid patch leaves the file byte-identical.

```sh
vincent config get                            # every key, one per line
vincent config set max_parallel_tasks 6
vincent config set notify.on "blocked awaiting_gate"
```

The write is comment-preserving: the daemon edits a key in place, or uncomments
its documented block where it stands, so the commented template this page
describes is still there afterwards. See
[`vincent config`](cli.md#vincent-config).

The TUI's daemon view shows and edits the same thing, alongside the adapters
detected and the log tail — `tab`, then `enter` on a key. Six keys
(`notify.command`, `environment`, `agents.*.path`, `listen`,
`triggers.enabled`, `backup.dir`) ask for a confirmation first.

---

## See also

- [Files and directories](files.md) — where `{config_dir}` and `{data_dir}` are.
- [Workflow schema](workflow-schema.md) — the per-step overrides for these
  defaults.
- [Agent CLIs](../guides/agents.md).

{% endraw %}
