Vibe Factory
Vibe Factory is an open, extensible framework for autonomous multi-agent software development, written in Rust.
You describe what you want. A pipeline of cooperating AI agents assesses the request, explores your codebase, writes a specification, plans the work, implements it in an isolated git workspace, reviews the result against the acceptance criteria, fixes what the review found, and hands you a branch ready to merge. Everything it did stays visible afterwards: the history of each task, the activity of every task in one feed, and the complete trace of every tool call, from the command line, a terminal UI, a local web UI or a macOS app.
task ──▶ assess ──▶ spec ──▶ plan ──▶ build ──▶ qa ──▶ fix ──▶ merge
│ ▲ │
└──────────┴───────┘
Why another agent framework?
Most autonomous coding tools are products: a fixed set of agents, one model vendor, one user interface. Vibe Factory is a framework. Everything that could reasonably differ between teams is an extension point with a small, documented trait:
| You want to… | Implement or configure |
|---|---|
| use a different model vendor or a local model | ModelProvider |
| give agents a new capability | Tool |
| change how an agent thinks or what it may touch | AgentSpec (TOML, no code) |
| isolate work in a container instead of a worktree | WorkspaceProvider |
| remember lessons across runs | MemoryStore |
| audit, veto or enrich what agents do | Hook |
| ship all of the above as a package | Plugin (in-process); tools, agents and hooks also from any language over stdio |
The default pipeline is itself built only from these extension points, so anything the framework can do, a plugin can do too.
What is in the box
- Four interfaces on one engine — the
vibecommand line, a terminal UI (vibe tui), a local web UI and HTTP API (vibe serve), and a native macOS app built on that API; they all read the same events, so they always agree. A VS Code extension is a client ofvibe servetoo. - See what was done —
vibe historyfor what each task delivered (runs, commits, changed files, validations, QA verdict, tokens, active time, and a cost when you give prices),vibe eventsfor the activity of every task, live or replayed, andvibe tracefor every tool call of a run with its complete arguments, exit code and output. - Human control — approvals of the spec, the plan or the merge, token and time budgets, cancellation and resume from any interface.
- Providers — Anthropic, any OpenAI-compatible API (OpenAI, Groq, Mistral, xAI, OpenRouter, Ollama), and a scriptable mock for tests and dry runs.
- Tools — read, write, edit, list, glob, grep and a sandboxed shell, all confined to the workspace and governed by a security policy.
- Workspaces — git worktree isolation with fast-forward, merge and conflict reporting, or in-place mode for quick experiments.
- Agents — complexity assessor, spec gatherer, researcher, writer and critic, planner,
coder, QA reviewer and fixer, merge resolver and commit-message writer, all overridable
from
.vibe/agents/*.toml. - Plugins — a JSON-RPC-over-stdio protocol shaped like the Model Context Protocol, so existing MCP servers work as tool plugins and new plugins can be written in any language.
- Documentation — this book: a user guide, a design book with architecture decision records, and generated API docs.
Where to go next
- New user: start with Installation and the Quick start, then see History and Tool call trace to look back at what the agents did.
- Contributor or curious engineer: read the Architecture overview.
- Extending the framework: see Extension points and Plugins.
Vibe Factory is licensed under MIT or Apache-2.0, at your option.
Installation
Vibe Factory is a single binary, vibe, built from source with cargo. It runs on macOS,
Linux and Windows.
Requirements
| Requirement | Version | Why |
|---|---|---|
| Rust toolchain | 1.88 or newer (edition 2024) | to build vibe; install with rustup |
| git | any recent version | task workspaces are git worktrees; also needed by cargo install --git |
| a model provider | an Anthropic or OpenAI key, any OpenAI-compatible endpoint, or a local Ollama | |
| Ollama (optional) | to run models locally without a key |
Check what you have:
rustc --version # rustc 1.88.0 or newer
git --version
If rustc is older, run rustup update stable.
The project you point vibe at should be a git repository: the default workspace creates a
worktree per task. For a directory without git, use the in_place workspace (see
Workspaces and merging).
Installing from git
cargo install --git https://github.com/vincentlauriat/vibe-factory vibe-cli
This builds the vibe-cli package in release mode and places the vibe binary in
~/.cargo/bin (%USERPROFILE%\.cargo\bin on Windows), which rustup adds to your PATH.
The first build downloads and compiles the dependencies and takes a few minutes.
To install a tagged release or a branch (replace the tag with one that exists):
cargo install --git https://github.com/vincentlauriat/vibe-factory --tag v0.5.1 vibe-cli
cargo install --git https://github.com/vincentlauriat/vibe-factory --branch main vibe-cli
Building from source
git clone https://github.com/vincentlauriat/vibe-factory
cd vibe-factory
cargo build --release -p vibe-cli
./target/release/vibe --version
To install your local checkout as vibe:
cargo install --path crates/vibe-cli
The rest of the workspace builds and tests the same way:
cargo build --workspace
cargo test --workspace
mdbook serve docs # this book, on http://localhost:3000
The repository also contains an example plugin, built separately:
cargo build --release -p vibe-plugins --bin vibe-echo-plugin
Verifying the installation
vibe --version
vibe 0.5.1
Then, inside a project:
cd your-project
vibe init
vibe doctor
vibe doctor checks git, the project repository, the configuration, provider keys,
plugins, the workspace setting and that .vibe/ is writable, one line per check. A missing key is expected at
this point if you have not set one yet; the Quick start continues from here.
Setting up a model provider
With the default configuration, vibe uses anthropic/claude-sonnet-5 and reads the key
from ANTHROPIC_API_KEY:
export ANTHROPIC_API_KEY=sk-ant-… # bash, zsh
set -gx ANTHROPIC_API_KEY sk-ant-… # fish
$env:ANTHROPIC_API_KEY = "sk-ant-…" # PowerShell
For OpenAI, set OPENAI_API_KEY and a default model:
export OPENAI_API_KEY=sk-…
vibe config set default_model openai/gpt-5
For a local model with Ollama, no key is needed:
ollama pull qwen2.5-coder
ollama serve # if not already running as a service
vibe config set default_model ollama/qwen2.5-coder
Other vendors and gateways are configured in Providers and models.
Shell completions
vibe completions <shell> prints a completion script for bash, zsh, fish, elvish
or powershell.
| Shell | Install |
|---|---|
| bash | vibe completions bash > ~/.local/share/bash-completion/completions/vibe |
| zsh | vibe completions zsh > ~/.zfunc/_vibe, with fpath+=~/.zfunc before compinit in ~/.zshrc |
| fish | vibe completions fish > ~/.config/fish/completions/vibe.fish |
| PowerShell | vibe completions powershell >> $PROFILE |
Open a new shell afterwards. Regenerate the script after upgrading, since new options only appear in a fresh script.
Upgrading
Run the install command again with --force:
cargo install --force --git https://github.com/vincentlauriat/vibe-factory vibe-cli
From a checkout: git pull then cargo install --path crates/vibe-cli --force.
Project data is forward-compatible: fields added to persisted files have defaults, so tasks
created by an older version still load. Read the changelog before upgrading across a minor
version, and finish or pause running tasks first. Your .vibe/config.toml is never rewritten
by an upgrade; vibe config show --default shows the new defaults so you can compare.
Uninstalling
cargo uninstall vibe-cli
This removes the binary only. Per-project data lives in each project’s .vibe/ directory;
discard task worktrees first so that their branches are removed too, then delete the
directory:
vibe task list --all # tasks whose worktrees you may want to discard
vibe task discard <ref> --yes # for each task
rm -rf .vibe
git worktree prune
User-level plugins live in your configuration directory (~/.config/vibe/ on Linux,
~/Library/Application Support/vibe/ on macOS, %APPDATA%\vibe\ on Windows); remove it if
you installed any.
Platform notes
macOS and Linux. The bash tool runs commands with sh -c in the workspace, in their
own process group, so a timeout kills the whole process tree. Any POSIX sh works; the
commands agents write (cargo test, npm test, grep, …) must be installed on your
machine, as for a human developer.
Windows. The bash tool runs commands with cmd /C, and a timeout kills the process
tree with taskkill /T /F. The security policy still parses commands with POSIX shell
rules, so constructs specific to cmd (for example %VAR% expansion or ^ escapes) may be
rejected as unparseable; commands such as cargo test or git status work unchanged.
Programs must be on PATH. Paths given to tools are resolved against the workspace and must
stay inside it, as on other platforms, and a bare drive root (C:\) is never a valid target
for rm. Long paths inside deep worktrees
can hit the 260-character limit: enable long paths in git
(git config --global core.longpaths true) and in Windows, or keep the project close to the
drive root.
All platforms. Worktrees live inside the project, under .vibe/worktrees/, so they are
on the same file system as your checkout and need roughly one extra working copy of disk
space per open task (see Workspaces and merging).
Quick start
This walkthrough takes about ten minutes. It runs one small task on an existing repository,
from vibe init to a merged branch. It assumes vibe is installed (Installation)
and uses a small Rust command-line project as the example; any language works the same way.
1. Choose a model
With an Anthropic key:
export ANTHROPIC_API_KEY=sk-ant-…
Or, with no key at all, a local model through Ollama:
ollama pull qwen2.5-coder
You will point the project at Ollama in step 2. To try the pipeline without any model, jump to Try it with no API key.
2. Initialise the project
cd ~/src/export-tool
git status # a clean git repository is the easiest start
vibe init
vibe init writes .vibe/config.toml with every key at its default value and a commented
[[plugins]] example, and adds worktrees/ and tool-output/ to .vibe/.gitignore
(tasks/ stays committed on purpose). Inspect the effective configuration:
vibe config show
default_provider = "anthropic"
default_model = "anthropic/claude-sonnet-5"
plugins = []
[phases]
[providers.anthropic]
kind = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
[providers.ollama]
kind = "openai"
base_url = "http://localhost:11434/v1"
default_model = "qwen2.5-coder"
[providers.openai]
kind = "openai"
api_key_env = "OPENAI_API_KEY"
[pipeline]
max_qa_rounds = 3
max_subtask_attempts = 3
max_parallel_subtasks = 3
max_phase_retries = 2
workspace = "git_worktree"
auto_merge = false
merge_strategy = "manual"
[security]
blocked_commands = []
allowed_commands = []
command_timeout_secs = 120
allow_network = false
extra_read_paths = []
Every phase uses default_model; [phases] is empty until you give a phase its own model.
With Ollama, switch the default model:
vibe config set default_model ollama/qwen2.5-coder
Then check everything is in place:
vibe doctor
Every key is explained in Configuration.
3. Add a task
vibe task add "Add a --json flag to the export command" \
-d "When --json is given, print the exported records as a JSON array on stdout instead of the table. Keep the table as the default. Add a test."
✓ created task #1 Add a --json flag to the export command
id 6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19
dir /home/me/src/export-tool/.vibe/tasks/001-add-a-json-flag-to-the-export-command
Run it with: vibe run 1
The task is stored in .vibe/tasks/001-add-a-json-flag-to-the-export-command/task.json, in
the backlog status. vibe task list shows it.
4. Plan without building
A dry run stops after planning, so you can read what the agents intend before they touch any code:
vibe run 1 --dry-run
It runs three phases:
| Phase | Agent(s) | Produces |
|---|---|---|
| assess | complexity_assessor (unless the heuristic already knows) | the complexity and profile, in task.json and run.json |
| spec | spec_gatherer, then spec_writer for a standard task | spec.json and spec.md: summary, requirements with acceptance criteria, relevant files |
| plan | planner | plan.json and plan.md: phases of subtasks with files, dependencies and verification |
The files are in the task directory:
cd .vibe/tasks/001-add-a-json-flag-to-the-export-command
cat spec.md plan.md progress.md
cd -
progress.md has one timestamped note per step: the assessment and its source, the spec
summary, the plan. The dry run leaves the run paused and the task back in backlog, ready to continue;
the exit code is 0 because it stopped where you asked.
If the spec or plan misses the point, edit spec.json and run vibe run 1 --from plan --dry-run, or discard the task and add it again with a better description.
5. Run it
vibe run 1 --resume
--resume continues the paused run at the build phase, reusing the spec and plan you just
read. (vibe run 1 would start over from the assessment.) The output looks like this:
Resuming #1 Add a --json flag to the export command (model anthropic/claude-sonnet-5, workspace git_worktree)
▶ run 3b1f09c2
── build ──────────
▸ subtask 1/3 Add the --json option to the CLI definition: in_progress
● coder · subtask 1/3 Add the --json option to the CLI definition
⟶ read_file(path: "src/cli.rs")
⟵ ok (2 ms)
⟶ edit_file(new_string: " /// Print records as JSON.\n #[arg(long)]\n pub json: bool,\n…
⟵ ok (4 ms)
⟶ bash(command: "cargo build")
⟵ ok (8412 ms)
● coder finished: 7 step(s), 31.4k in / 1.9k out tokens, completed
▸ subtask 1/3 Add the --json option to the CLI definition: done
▸ subtask 2/3 Serialise records as JSON in export: in_progress
● coder · subtask 2/3 Serialise records as JSON in export
⟶ grep(path: "src", pattern: "fn export")
⟵ ok (12 ms)
⟶ edit_file(new_string: " if args.json {\n serde_json::to_writer_pretty(std::io::st…
⟵ ok (3 ms)
⟶ bash(command: "cargo test export")
⟵ error (6120 ms): exit status 101 --- stdout --- running 4 tests test export::tests::json_out…
⟶ edit_file(new_string: "#[derive(Serialize)]\npub struct Record {", old_string: "pub stru…
⟵ ok (2 ms)
⟶ bash(command: "cargo test export")
⟵ ok (5810 ms)
● coder finished: 12 step(s), 58.0k in / 3.4k out tokens, completed
▸ subtask 2/3 Serialise records as JSON in export: done
▸ subtask 3/3 Test the --json output: in_progress
…
▸ subtask 3/3 Test the --json output: done
✓ build: 3 done, 0 failed, 0 skipped (4 min 12 s)
── qa ──────────
● qa_reviewer
⟶ bash(command: "cargo test")
⟵ ok (9204 ms)
● qa_reviewer finished: 15 step(s), 39.8k in / 1.9k out tokens, completed
✓ qa: round 1: Approved (0 issue(s)) (1 min 03 s)
── merge ──────────
✓ merge: ready for human review and merge (auto_merge is off) (3 ms)
■ run finished: ready
phase result time tokens in tokens out summary
build ✓ 4 min 12 s 176.3k 7.9k 3 done, 0 failed, 0 skipped
qa ✓ 1 min 03 s 39.8k 1.9k round 1: Approved (0 issue(s))
merge ✓ 3 ms 0 0 ready for human review and merge (auto_mer…
status ready
duration 5 min 16 s
tokens 216.1k in / 9.8k out
branch vibe/add-a-json-flag-to-the-export-command-6f1c3e0a
worktree /home/me/src/export-tool/.vibe/worktrees/add-a-json-flag-to-the-export-command-6f1c3e0a
task dir /home/me/src/export-tool/.vibe/tasks/001-add-a-json-flag-to-the-export-command
Review the work, then merge it: git merge vibe/add-a-json-flag-to-the-export-command-6f1c3e0a
A ── header opens each phase and a ✓ (or ✗) line closes it with its summary and
duration. ● lines mark agent sessions, ▸ lines subtask status changes. Each ⟶ line is a
tool call with its arguments (cut at 80 characters) and each ⟵ line its result with the
time it took. A failed tool call (error) is normal: the agent reads the error and tries
again. With -v, the agents’ own text and informational logs are shown too. The final table
sums up the phases of this invocation. Ctrl-C cancels; vibe run 1 --resume continues later.
Exit code 0 means the task is ready (or done with auto-merge).
6. Inspect the result
vibe task show 1
This prints the spec summary, the plan with each subtask’s status, the QA verdict, the state of the run and the workspace. The code is on the task branch, in its own worktree:
git log --oneline main..vibe/add-a-json-flag-to-the-export-command-6f1c3e0a
git diff main...vibe/add-a-json-flag-to-the-export-command-6f1c3e0a
cd .vibe/worktrees/add-a-json-flag-to-the-export-command-6f1c3e0a && cargo test && cd -
Each completed subtask was committed as vibe: complete subtask <n> - <title>.
7. Merge
The task is ready: nothing reached your branch yet. Merge it as any branch:
git switch main
git merge vibe/add-a-json-flag-to-the-export-command-6f1c3e0a
The worktree and the branch stay until you discard the task. vibe task discard 1 --yes
removes them, together with the task directory and its traced tool outputs, so the task no
longer appears in its history: run it once you no longer need to look back at the task
(step 8).
To let Vibe Factory merge by itself as soon as QA approves, run with --auto-merge, or
set it for the project:
vibe run 2 --auto-merge
vibe config set pipeline.auto_merge true
Auto-merge fast-forwards when it can, otherwise creates a merge commit. It refuses to run
while tracked files of your checkout have uncommitted changes, and it leaves the task
ready with the list of conflicting files when the branches conflict. Details in
Workspaces and merging.
8. See what was done
Everything a run did stays readable after it ended, rebuilt from the task’s events and git:
vibe history # every finished task: runs, commits, files, tokens, time, cost
vibe history 1 # one task: its runs, commits, changed files, validations, QA
vibe trace 1 # every tool call of the last run, with its result
vibe trace 1 --full # … and the complete output of each call
vibe events --since 1d # what happened on every task today, in time order
vibe events --follow # the activity of every task from now on (Ctrl-C to stop)
vibe history shows a cost only once you give model prices in
[pricing]. The complete tool outputs vibe trace --full reads
are kept under .vibe/tool-output/, ignored by git; see Tool call trace for
their disk usage and History for where each figure comes from.
The same views exist outside the command line:
vibe tuiopens a terminal UI: the task board (T), the activity of every task (A), the history (H), and a Trace tab in the task detail;vibe serveserves a web UI onhttp://127.0.0.1:7777(open the link it prints, which carries the access token) with the Tasks, Activity, History and Evaluations views and a Trace tab per task, and the HTTP API they use;- the macOS app in
apps/macos/VibeFactoryopens a project folder and startsvibe servefor it, or connects to one already running.
Try it with no API key
The built-in mock provider answers every agent with canned, valid documents without
calling any model. It edits no file, so it is only good for seeing the pipeline move:
vibe task add "Add a --json flag to the export command"
vibe run 1 --provider mock --dry-run
vibe task show 1
This is the CLI’s built-in mock, used whenever no [providers.mock] table is declared. A
provider you declare yourself with kind = "mock" only answers plain text, so its structured
phases fail. The mock assessor classifies the task as simple, so the spec is written by the gatherer
alone, and the planner returns a one-subtask plan. Drop --dry-run to go through build, QA
and merge as well.
To control what the “model” says, give a script with --script. A script is a JSON array of
answers replayed in order, or an object with answers per agent role:
{
"routes": {
"planner": [
{"json": {"approach": "Two steps.", "phases": [{"name": "Work", "parallel": false,
"subtasks": [{"title": "Add flag", "description": "Add --json."},
{"title": "Test it", "description": "Add a test.", "depends_on": ["Add flag"]}]}]}}
],
"qa_reviewer": [
{"json": {"verdict": "changes_requested", "summary": "Missing test.",
"issues": [{"severity": "high", "title": "No test for --json"}]}}
]
},
"fallback": true
}
vibe run 1 --script demo.json # implies the mock provider on every phase
Each request takes the next answer routed to its role, else the next entry of steps, else,
with fallback true (the default), the canned answer. An answer is a string (plain text),
{"text": "…"}, {"json": …} (sent as a fenced JSON document, as structured agents
answer), or {"tool": "read_file", "input": {"path": "src/main.rs"}} (a tool call, which
really runs). With "fallback": false, an exhausted script fails the request. Roles are
recognised from the first line of the agent’s system prompt, so routes work for the built-in
prompts; an agent whose prompt you replaced is only served by steps and the fallback. The example
above makes QA request changes once, so you can watch the fix loop: the fixer runs, QA
reviews again with the canned approval, and the task ends ready.
Next steps
- Concepts explains tasks, profiles, specs, plans and workspaces.
- Configuration lists every setting, including models per phase.
- History and Tool call trace explain what
vibe historyandvibe traceshow and where it comes from. - Troubleshooting covers what to do when a run pauses or fails.
Concepts
This page introduces the vocabulary used throughout Vibe Factory. The Glossary has one-line definitions; this page explains how the pieces fit together.
Task
A task is the unit of work you hand to the framework: a title and a description, in
plain language, of what you want. Tasks live on a board with the statuses backlog,
planning, building, review, ready, done, failed and cancelled.
vibe task add "Add OAuth login with GitHub" \
--description "Users should be able to sign in with their GitHub account…"
Complexity
Before doing anything expensive the pipeline assesses the task: trivial, simple,
standard or complex. The class picks a profile: a trivial fix skips the
specification; a complex feature adds research and a self-critique of the spec.
Specification
The spec captures what must be built and how success will be checked: a summary, a
list of requirements (functional, non-functional, constraints) each with acceptance
criteria, and the context gathered from your codebase (relevant files, conventions,
assumptions). It is stored both as JSON and as a readable spec.md.
Plan and subtasks
The plan breaks the spec into ordered phases of subtasks. A subtask is small enough for one fresh agent session: a title, a precise description, the files to touch, the subtasks it depends on, and how to verify it. Subtasks without dependencies on each other can run in parallel.
Agents and roles
An agent is a model running in a loop with tools. Vibe Factory ships one agent per
role: complexity assessor, spec gatherer, researcher, writer and critic, planner, coder,
QA reviewer, QA fixer, merge resolver and commit-message writer. Each role has a system
prompt, a set of allowed tools, a model and a thinking level. All of it is configurable
from .vibe/agents/*.toml without writing code.
Tools
Tools are what agents can do: read, write and edit files, list and search the tree, and run shell commands. Tools are confined to the workspace and governed by a security policy. Plugins can add tools (a browser, a database client, an issue tracker…).
Workspace
Agents never work in your checkout by default. The workspace is a git worktree on a
dedicated branch, created per task. When QA approves, the branch is merged (or left for
you to review). in_place mode disables isolation for quick experiments.
Pipeline and phases
The pipeline runs the phases assess → spec → plan → build → qa → fix → merge. Each
phase reads the artefacts of the previous ones and writes its own into
.vibe/tasks/<task>/. A run can be watched live, interrupted, and resumed.
Events, trace and history
Everything a run does is published as an event (a phase started, an agent called a
tool, a subtask was committed, the run finished with its totals) and appended to the task’s
events.jsonl. Events are the contract between the engine and every interface: the live
output of vibe run, vibe events, the terminal UI, the web UI and the macOS app all show
events, and none of them keeps a state of its own (Events).
What does not fit in an event is referenced by one. The complete output of each tool call
goes to the trace store, .vibe/tool-output/, local and ignored by git, and the
tool_returned event points to it (Tool call trace).
A read layer turns events and git back into views: the activity of every task in time
order, the history of what a task delivered (runs, commits, changed files, validations,
QA verdict, tokens, active time, cost; see History), and the trace of a
run’s tool calls. The command line, the terminal UI and vibe serve use the same read
layer, so they always agree.
Providers and models
A provider is a model vendor or API (Anthropic, OpenAI, a local Ollama…). Models are
referred to as provider/model, for example anthropic/claude-sonnet-5 or
ollama/qwen2.5-coder. Each phase can use a different model.
Memory
Memory is what survives a single run: gotchas discovered while coding, conventions, decisions. The default store is a set of files in the task directory; plugins can provide richer back-ends.
Plugins and hooks
A plugin bundles extensions: tools, agents, providers, workspaces, memory stores and
hooks. A hook observes or vetoes what agents do (for example “never let an agent run
npm publish”) and can enrich prompts with project rules. In-process (Rust) plugins can
contribute every kind of extension; out-of-process plugins, written in any language
speaking the plugin protocol, contribute tools, agents and the before_tool hook.
Configuration
A project is configured by one file, .vibe/config.toml, written by vibe init. Every key
is optional: a missing key takes the default listed below, and a missing file means “all
defaults”. This page is the complete reference, generated from the VibeConfig type in
crates/vibe-core/src/config.rs.
vibe config path # where the file is
vibe config show # the effective configuration (file + defaults)
vibe config show --default # the built-in defaults only
Top-level keys
| Key | Type | Default | Meaning |
|---|---|---|---|
default_provider | string | "anthropic" | provider name used when a model reference has no provider/ prefix |
default_model | string | "anthropic/claude-sonnet-5" | model used by every phase without a [phases.<phase>] entry |
base_branch | string | unset | branch that task worktrees fork from and merge into; unset means the branch currently checked out |
[phases.<phase>] | table | none | model and thinking level for one phase |
[providers.<name>] | table | the three built-ins | model back-ends |
[pipeline] | table | see below | pipeline tuning |
[security] | table | see below | tool security policy |
[[plugins]] | array of tables | none | out-of-process plugins to start |
[workspace.container] | table | none | settings of the container workspace; see below |
[integrations] | table | none | GitHub and GitLab access for vibe task import and vibe pr; see below |
[pricing."<provider>/<model>"] | table | none | model prices, to show what a task cost in vibe history; see below |
Unknown keys are ignored by the TOML reader, so check spelling with vibe config show: a
key that does not appear there had no effect.
Models and thinking per phase
The phases are assess, spec, plan, build, qa, fix and merge.
[phases.plan]
model = "anthropic/claude-opus-5" # required in the table
thinking = "high" # optional: overrides every agent of the phase
| Key | Type | Default | Meaning |
|---|---|---|---|
model | string, required | none | provider/model, or a bare model on default_provider, or provider/ for that provider’s default model |
thinking | off, low, medium, high, max | unset | when set, thinking level of every agent run during this phase |
How a model is chosen for an agent:
- an agent that pins a model (
model = "provider/model"in.vibe/agents/<role>.toml) uses it; - otherwise
[phases.<phase>].model; - otherwise
default_model.
How a thinking level is chosen: an agent uses its own level (the built-in table in
Customising agents, or the thinking of an agent file) unless the current
phase has a [phases.<phase>] table that sets thinking, in which case that value applies
to every agent of the phase. A [phases.build] table that sets only model leaves each
agent’s own thinking level untouched.
Model references and shorthands are described in Providers and models.
[providers.<name>]
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | string, required | none | anthropic, openai, ollama, mock, or a kind registered by a plugin |
api_key | string | unset | the key itself; wins over api_key_env; do not commit it |
api_key_env | string | unset | environment variable holding the key; an empty value counts as missing |
base_url | string | the vendor endpoint | gateway, proxy or self-hosted server |
default_model | string | provider-specific | model used for provider/ or provider/default |
extra | table | empty | kind-specific settings (headers, auth mode, …) |
When the file contains no [providers] table at all, these three are used:
[providers.anthropic]
kind = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
[providers.openai]
kind = "openai"
api_key_env = "OPENAI_API_KEY"
[providers.ollama]
kind = "openai"
base_url = "http://localhost:11434/v1"
default_model = "qwen2.5-coder"
As soon as you declare one [providers.<name>] table, the built-in list is replaced by the
tables you wrote. Declare every provider you reference. The extra keys and ready-made
recipes (Groq, OpenRouter, xAI, Mistral, mock) are in Providers and models.
[pipeline]
| Key | Type | Default | Meaning |
|---|---|---|---|
max_qa_rounds | integer | 3 | QA reviews per invocation of vibe run; when reached without approval, the run pauses with the task in review (minimum 1) |
max_subtask_attempts | integer | 3 | attempts per subtask; the last one uses the coder_recovery agent; then the subtask is failed (minimum 1) |
max_parallel_subtasks | integer | 3 | subtasks implemented at the same time (minimum 1) |
max_phase_retries | integer | 2 | extra planner attempts when the plan is invalid (so 3 attempts by default) |
workspace | string | "git_worktree" | git_worktree, in_place, or a workspace provider registered by a plugin |
auto_merge | bool | false | merge after QA approval and required validations; otherwise stop in ready |
validation_commands | list of strings | [] | mandatory shell checks before ready/merge; see below |
max_validation_fix_attempts | integer | 2 | automatic validation fixes over the whole run, including resumes; 0 disables them |
merge_strategy | "manual" or "assisted" | "manual" | manual reports conflicts for a human; assisted lets a model resolve conflict markers first |
isolate_subtasks | bool | true | with git_worktree, one worktree per subtask attempt, integrated one at a time; see Workspaces |
project_memory | bool | true | keep lessons (pitfalls, conventions) in .vibe/memory.jsonl and recall the relevant ones in later tasks; see vibe memory |
approvals | list of "spec", "plan", "merge" | [] | where the run waits for a human decision; see Human approvals |
max_tokens | integer | none | input plus output tokens of the whole run, including resumes; the run pauses when reached |
max_duration_secs | integer | none | active time of the whole run in seconds, including resumes; the run pauses when reached |
trace_outputs | bool | true | keep the complete output of every tool call in .vibe/tool-output/<task dir>/<run>/<call>.txt (ignored by git), referenced by the tool_returned events and read by vibe trace --full; see Tool call trace for disk usage and privacy |
trace_max_chars | integer | 100000 | characters kept per traced tool output; longer outputs are cut and end with a marker line. Only the trace is affected: what the model sees is truncated separately |
What these limits do at run time is described in The pipeline; workspaces and merging in Workspaces and merging.
Required validation commands
[pipeline]
validation_commands = ["cargo fmt --all -- --check", "cargo test --offline"]
max_validation_fix_attempts = 2
The default is [] for compatibility. Each configured command is mandatory and runs
sequentially from the task workspace root at the start of the merge phase, after normal
QA approval and before either marking the task ready or merging it automatically.
The pipeline calls the registered bash tool directly, without asking a model to interpret
its result. Tool hooks and the existing shell policy still apply. Custom bash tools must
return structured exit_code: 0 metadata for success; tool registrations are trusted.
The timeout is security.command_timeout_secs (bounded by the shell tool to 1–600 seconds).
A nonzero exit or timeout sends the command, exit status and captured output to the QA fixer. The fixer commits its changes, a fresh QA review runs, then every required command runs again before integration. This applies even to the trivial complexity profile. A favorable model verdict alone never satisfies a command check.
max_validation_fix_attempts defaults to 2 and limits automatic validation corrections over
the whole run, including resumes. Set it to 0 for manual correction only. Attempts are
reserved and saved before invoking the fixer, so an interrupted attempt still consumes one.
The existing QA review/fix limits also remain in effect. After exhausting the validation
budget, the run pauses with task status review. A denied/empty command, missing tool or
execution error without an exit status pauses immediately for a human; it is not sent to
an agent to work around the policy. Later validation commands do not run after a failure.
Results, output and metadata remain in run.json under validations; the persisted
validation_fix_attempts counter and pending_validation_fix index track correction and
recovery. Notes are also written to progress.md. Correct the workspace or configuration
and use vibe run <task> --resume: the gate rechecks all commands without granting fresh
automatic attempts. To grant more attempts explicitly, increase the configured limit.
Cancellation is checked between commands and before merge; a running command remains
bounded by its timeout.
Use checking commands, not formatters that rewrite sources. With auto_merge = true,
the commands also run on an isolated integration candidate that combines the latest target
branch and the task, after any assisted conflict resolution. Validation records include
integration (false for task checks, true for integration checks) and workspace_root.
A failed integration check pauses for human review without spending task-fixer attempts;
the target branch is not updated. Resuming rebuilds a candidate from the current target.
The git provider rejects tracked changes, new non-ignored files or commits left by a check in the candidate. Build outputs must be ignored. It also refuses publication if the target branch, current checkout, tracked files or guarded repository configuration changed during validation. Only the already-tested commit can be fast-forwarded into the target. Temporary candidate worktrees are removed after success or failure; the persisted output and progress notes remain. This validates repository content, not production deployment.
With no validation commands the previous merge behavior is preserved. In-place mode has
no separate integration: checks run again on the project itself, with no rollback guarantee.
Third-party workspace providers must implement merge_validated to support gated automatic
integration; the default refuses it rather than falling back to an unchecked merge.
External manual git merge commands are outside this gate. Configuration is loaded by the
host, not accepted from an agent’s QA report.
Run budgets
[pipeline]
max_tokens = 2_000_000 # input + output tokens over the whole run
max_duration_secs = 3600 # one hour of active work
Both limits are off by default. They apply to a run, not to one invocation of
vibe run: run.json keeps the tokens (usage) and the active time (active_ms) of every
invocation, and a resumed run starts from those totals. Time spent paused or between
invocations is not counted.
Agents add the tokens of every model call to the budget as it happens. Once a limit is
reached no new model call starts: running sessions stop before their next step,
interrupted subtasks go back to pending, and the run pauses (exit code 2) at the phase
it was in, with a note in progress.md naming the limit. A running shell command is not
interrupted; it is bounded by its own timeout. Resuming without raising the limit pauses
again immediately and spends nothing. Raise the limit in the configuration, or for one
invocation with vibe run <REF> --resume --max-tokens N --max-duration 2h, to continue.
The token limit counts every model call of the run: the agents’ calls and the ones made by
merge_strategy = "assisted" to resolve conflict markers.
Human approvals
[pipeline]
approvals = ["plan", "merge"]
Each listed gate pauses the run (exit code 2, task review) until a human decides:
| Gate | The run stops | A rejection goes back to |
|---|---|---|
spec | after the specification, before planning (only when the profile writes a spec) | the spec phase |
plan | after the plan, before building | the planner |
merge | after QA approval, before the merge phase (validation, integration or marking ready) | the fixer, through a QA report titled “Human review”, then QA again |
vibe task show 3 # read the spec, plan or QA report
vibe approve 3 --comment "ok" # or:
vibe reject 3 --reason "Split the migration into its own subtask"
vibe run 3 --resume
The decision is stored in run.json (approvals, pending_approval, rejection) and
logged as an approval_resolved event. A rejection needs a reason: it is handed to the
agents that redo the work. Regenerating an artefact revokes its approval, so a new plan is
approved again, and a merge is approved again after any new build or fix. Resuming without
a decision pauses again at the same gate.
[workspace.container]
Settings of the container workspace (pipeline.workspace = "container"). Only image is
required; unknown keys are refused.
| Key | Type | Default | Meaning |
|---|---|---|---|
image | string | required | image the commands run in |
runtime | string | "docker" | docker or podman, or a path to one of them |
network | string | "none" | none, bridge or a named network; attached only when security.allow_network is true |
mounts | list of tables | [] | extra bind mounts: source, target, read_only (default true) |
cpus | number | none | --cpus |
memory | string | none | --memory and --memory-swap, such as 4g |
pids_limit | integer | 1024 | maximum number of processes |
tmp_size | string | runtime default | size of the /tmp tmpfs |
user | string | owner of the worktree (Unix) | uid[:gid] or name[:group] |
env | list of names | [] | host environment variables passed through |
mount_git_metadata | bool | false | mount the repository’s git directory read-only (Unix only) |
See Container workspace for what each setting allows.
[security]
| Key | Type | Default | Meaning |
|---|---|---|---|
blocked_commands | list of strings | [] | programs denied in addition to the built-in list |
allowed_commands | list of strings | [] | when non-empty, only these programs (plus harmless builtins) may run |
command_timeout_secs | integer | 120 | default timeout of a shell command; an agent may ask for up to 600 |
allow_network | bool | false | grant the network permission (curl, wget, ssh, … and network-using tools) |
extra_read_paths | list of paths | [] | directories outside the workspace that agents may read, never write |
web_allowed_domains | list of strings | [] | domains (and subdomains) web_fetch may reach; empty means any public host |
search_url | string | none | SearXNG-compatible search URL with {query}, e.g. https://searx.example/search?q={query}&format=json; registers web_search |
The rules behind these keys are in Tools and security.
[integrations]
[integrations.github]
api_url = "https://github.example.com/api/v3" # GitHub Enterprise; default https://api.github.com
token_env = "GHE_TOKEN" # default GITHUB_TOKEN, then GH_TOKEN
[integrations.gitlab]
api_url = "https://gitlab.example.com/api/v4" # default https://gitlab.com/api/v4
token_env = "GITLAB_TOKEN"
Used by vibe task import and vibe pr. Tokens are read from the environment only, never
from the configuration file.
[pricing]
[pricing."anthropic/claude-sonnet-5"]
input = 3.0 # USD per million prompt tokens
output = 15.0 # USD per million completion tokens
cache_read = 0.3 # optional: prompt tokens served from cache
cache_write = 3.75 # optional: prompt tokens written to cache
| Key | Type | Default | Meaning |
|---|---|---|---|
input | number, required | none | USD per million prompt tokens |
output | number, required | none | USD per million completion tokens |
cache_read | number | unset | USD per million prompt tokens read from the cache |
cache_write | number | unset | USD per million prompt tokens written to the cache |
Prices are only used to show the cost of a task in vibe history and its
web, terminal and API equivalents; they change nothing in a run. There are no built-in
prices: without this table no cost is shown. Every price must be a
finite number, zero or more; vibe refuses a configuration with any other value.
Events name a model by the provider’s model id, without the provider. A model id is
matched first against a key equal to it, then against every key "<provider>/<model id>",
the provider being the part of the key before its first /. So claude-sonnet-5 in a log
matches "claude-sonnet-5", else "anthropic/claude-sonnet-5"; a model id that contains a
/ itself, such as meta-llama/llama-3 served by openrouter, matches
"openrouter/meta-llama/llama-3". When two providers price the same model id differently,
the model counts as unpriced. A local model needs a table too, at zero, or tasks that used
it show no cost:
[pricing."ollama/qwen2.5-coder"]
input = 0.0
output = 0.0
A task’s cost is shown only when every agent session it ran has a price that covers the
tokens it used: one model without a price, cache tokens without a cache_read or
cache_write price, or a session logged before 0.5 (whose model is not recorded) and the
cost is left out rather than estimated. The cost is marked incomplete, a lower bound, when
a session started but never finished (a crash: its tokens were not logged) or when a run
used more tokens than its sessions account for (a call outside any agent session, such as
the repair of an invalid structured answer, whose model is not logged).
[[plugins]]
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string, required | none | unique name for logs and vibe plugins list |
command | list of strings | [] | program and arguments speaking the plugin protocol on stdio |
env | table of strings | {} | extra environment variables |
cwd | path | the project root | working directory of the process |
capabilities | list of tools, agents, hooks | [] | informational; the handshake decides |
required | bool | false | a failure to start stops the run instead of being skipped |
See Plugins for manifests under .vibe/plugins/ and user-level plugins.
A complete example
# .vibe/config.toml
# Models ------------------------------------------------------------------
default_provider = "anthropic"
default_model = "anthropic/claude-sonnet-5" # every phase without its own table
# base_branch = "develop" # default: the branch checked out
# A cheap, fast assessment; a strong planner and reviewer; a local coder.
[phases.assess]
model = "anthropic/haiku"
thinking = "low"
[phases.plan]
model = "anthropic/opus"
thinking = "high"
[phases.build]
model = "ollama/qwen2.5-coder:32b"
thinking = "off"
[phases.qa]
model = "anthropic/opus"
thinking = "high"
# Providers: declaring one table replaces the built-in list, so list them all.
[providers.anthropic]
kind = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
[providers.ollama]
kind = "ollama"
default_model = "qwen2.5-coder:32b"
# Pipeline ----------------------------------------------------------------
[pipeline]
max_qa_rounds = 3 # reviews per `vibe run` before pausing for a human
max_subtask_attempts = 3 # the third attempt uses the recovery agent
max_parallel_subtasks = 2 # keep the local model responsive
max_phase_retries = 2 # extra planner attempts on an invalid plan
workspace = "git_worktree" # or "in_place"
auto_merge = false # stop in `ready` and let me merge
merge_strategy = "manual"
max_tokens = 3_000_000 # pause the run beyond this, resumes included
max_duration_secs = 7200 # and beyond two hours of active work
trace_outputs = true # keep every tool output under .vibe/tool-output/ (git-ignored)
trace_max_chars = 50_000 # at most this many characters per call
# Security ----------------------------------------------------------------
[security]
blocked_commands = ["docker", "terraform"]
allowed_commands = [] # empty: everything not blocked may run
command_timeout_secs = 300 # long test suites
allow_network = false
extra_read_paths = []
# Plugins -----------------------------------------------------------------
[[plugins]]
name = "policy"
command = ["./tools/vibe-policy-plugin"]
required = true # never run without the policy hook
Remember that in TOML a key written after a [table] header belongs to that table: put
top-level keys such as base_branch and default_model before the first header.
Precedence
For each setting the first source that provides it wins:
- command-line flags of
vibe run:--complexity,--provider,--model,--workspace,--auto-merge(see Command line reference).--providerand--modelreplace the model of every phase, but not a model pinned by an agent file; .vibe/config.toml;- built-in defaults listed on this page.
Agent settings have their own layering, from lowest to highest: built-in agent, agents from
plugins, .vibe/agents/*.toml in file-name order; see Customising agents. The
thinking of a [phases.<phase>] table still overrides all of them for that phase.
Environment variables
| Variable | Used for |
|---|---|
ANTHROPIC_API_KEY | key of the built-in anthropic provider (api_key_env) |
ANTHROPIC_WORKSPACE_ID | workspace sent as anthropic-workspace-id by every anthropic provider, for an API key that is not scoped to a workspace; an extra.headers entry of the same name wins |
OPENAI_API_KEY | key of the built-in openai provider |
any name in an api_key_env | key of that provider |
RUST_LOG | log filter, for example RUST_LOG=vibe_pipeline=debug,vibe_plugins=debug; -v and -vv are shortcuts |
NO_COLOR | disables colours, like --no-color |
Keys are read when a provider is built, at the start of each command. A provider whose key is
missing still loads and fails with AuthFailed on its first call.
Changing values from the command line
vibe config set takes a dotted key and a value, edits the file in place and keeps the rest
of it, including comments. Values are parsed as TOML when they are valid TOML (numbers,
booleans, arrays, quoted strings) and as plain strings otherwise.
vibe config set pipeline.auto_merge true
vibe config set pipeline.max_parallel_subtasks 1
vibe config set default_model anthropic/claude-opus-5
vibe config set phases.plan.model openai/gpt-5
vibe config set phases.plan.thinking high
vibe config set security.blocked_commands '["docker", "kubectl"]'
vibe config set providers.ollama.base_url http://gpu-box:11434/v1
Missing tables are created. The whole configuration is parsed before the file is written,
so a value of the wrong type, or a [phases.<phase>] table without its required model, is
rejected (`phases.plan.thinking = high` makes the configuration invalid: …) and the
file is left unchanged: set model before thinking. The key name itself is not checked,
and a misspelled key is written and then ignored. Without a configuration file, set starts
from the defaults and creates it.
Agents
Per-role settings (prompt, tools, pinned model, thinking, step and token budgets) do not
live in config.toml but in .vibe/agents/*.toml. vibe agents export <role> writes the
built-in definition of a role there as a starting point. The format is described in
Customising agents.
Command line reference
The vibe binary drives everything: it creates tasks, runs them through the pipeline,
shows their state and inspects the configuration, agents and plugins. vibe help <command>
and vibe <command> --help print the same information as this page.
vibe [GLOBAL OPTIONS] <COMMAND>
init create .vibe/config.toml
task add | list | show | import | discard
run run a task through the pipeline
approve approve what a paused run waits for
reject reject it, with a reason for the agents
cancel stop the run of a task, from any terminal
pr push a ready task's branch and open a pull request
memory list | clear the project memory
tui terminal UI: board, activity, history, trace
serve HTTP API and web UI
events replay or follow the events of one task or of every task
history what finished tasks did: runs, commits, files, tokens, cost
trace the tool calls of a run: arguments, results, outputs
status project summary
config show | path | set
agents list | show | export
plugins list | check
doctor check the environment and the configuration
completions print a shell completion script
Global options
Global options may appear before or after the subcommand.
| Option | Effect |
|---|---|
-C, --project <DIR> | start directory; the project is this directory (default: the current one) or its nearest parent that contains a .vibe directory, without leaving the git repository (the search stops at the first parent that contains .git) and never climbing up to your home directory; vibe init never searches parents |
-v, --verbose | more logging on standard error: default warnings only; -v info, -vv debug, -vvv trace; a non-empty RUST_LOG wins |
--json | machine-readable output: one JSON document, or one JSON event per line for vibe run and vibe events (see JSON output) |
--no-color | no colours; also the case when NO_COLOR is set or the output is not a terminal |
-h, --help | help of the command |
-V, --version | version of vibe |
Task references
Commands that act on one task take a <REF>:
| Form | Example | Matches |
|---|---|---|
| number (up to six digits, leading zeros allowed) | 3, 003 | the task numbered 3 |
| directory name | 003-add-oauth-login-github | the task stored in .vibe/tasks/003-… |
| id prefix | 6f1c, 6f1c3e0a-9b2d | the task whose id starts with it; an ambiguous prefix is an error |
Numbers are tried first, then directory names, then id prefixes. A reference that matches nothing is an error.
vibe init
vibe init [--force]
Creates .vibe/config.toml in the project directory (the current directory unless -C is
given; parents are not searched): a two-line header comment, every key at its default value,
and a commented [[plugins]] example at the end, so you can append plugin tables. It refuses
to overwrite an existing file unless --force is given, which rewrites the defaults. In a
git repository it also writes .vibe/.gitignore, or completes an existing one without
duplicating lines, with worktrees/, tool-output/ and a comment explaining that tasks/
is committed on purpose. Outside git it warns that worktree isolation needs git. It ends by
printing the next steps; run vibe doctor afterwards. The file is described in
Configuration.
vibe task
vibe task add
vibe task add <TITLE> [-d <TEXT> | --description-file <PATH>] [--label <LABEL>]...
| Option | Effect |
|---|---|
-d, --description <TEXT> | the description; - reads it from standard input |
--description-file <PATH> | read the description from a file; - for standard input |
--label <LABEL> | add a label; repeat for several |
--description and --description-file are mutually exclusive. The task is created in
backlog and gets the next number.
vibe task add "Add a --json flag to the export command" \
-d "Print the exported records as a JSON array when --json is given." --label cli
git log -1 --format=%B | vibe task add "Follow up on the last commit" -d -
vibe task add "Migrate the settings page" --description-file notes/settings.md
vibe task list
vibe task list [--status <STATUS>] [--all]
Lists tasks by number with status, complexity and title. With --json, the output is an
array of {"number": …, "task": {…}} objects. done and
cancelled tasks are hidden unless --all is given. --status keeps one of backlog,
planning, building, review, ready, done, failed, cancelled.
vibe task show
vibe task show <REF>
Shows the task: description, status and complexity, the branch its last run recorded, spec summary and requirements, plan
with subtask statuses, the latest QA verdict and issues, the state of the last run
(run.json) and the workspace branch and directory. The files it reads are listed in
Persistence layout.
vibe task import
vibe task import octo/app#12
vibe task import https://gitlab.com/group/project/-/issues/5
vibe task import gitlab:group/project#5
Creates a task from a GitHub or GitLab issue: its title, its body (followed by a link back)
and its labels. The task remembers the issue (source), so importing it twice fails with
already imported as …, and vibe pr closes it. Private repositories need a token in
GITHUB_TOKEN (or GH_TOKEN) or GITLAB_TOKEN; see [integrations]
for GitHub Enterprise and self-managed GitLab. --forge gitlab makes the short form refer to
GitLab.
vibe task discard
vibe task discard <REF> [-y | --yes]
Removes the task’s workspace (worktree and branch; nothing for in_place), deletes the
task directory and the task’s traced tool outputs (.vibe/tool-output/<task dir>/), so the
task leaves the history too. It asks for confirmation on a terminal unless --yes is given; when standard
input is not a terminal it refuses to run without --yes. Unmerged work on the task branch
is lost.
vibe run
vibe run <REF> [OPTIONS]
Runs the task through the pipeline and renders its progress live.
| Option | Effect |
|---|---|
--complexity <C> | trivial, simple, standard or complex; skips the assessment |
--from <PHASE> | start at this phase, reusing the persisted spec and plan |
--until <PHASE> | pause (resumably) once this phase is done |
--dry-run | only assess, specify and plan; same as --until plan |
--provider <NAME> | use this provider for every phase, with its default model unless --model is given; mock selects the built-in mock when no [providers.mock] table exists |
--model <PROVIDER/MODEL> | use this model for every phase; a bare model name uses the default provider (or --provider) |
--workspace <NAME> | git_worktree, in_place, or a workspace provider from a plugin |
--auto-merge | merge automatically once QA approves |
--script <FILE> | drive the mock provider with scripted responses from a JSON file; implies --provider mock on every phase and cannot be combined with --provider or --model |
--resume | continue the last run of the task where it stopped |
--max-tokens <N> | pause the run once it used N tokens, counted across resumes; overrides pipeline.max_tokens |
--max-duration <D> | pause the run after D of active work, counted across resumes: seconds, or 90s, 15m, 2h; overrides pipeline.max_duration_secs |
Phases are assess, spec, plan, build, qa, fix, merge. These flags override the
configuration for this run only; nothing is written back. --provider and --model replace
default_model and the model of every [phases.*] table but keep their thinking levels;
an agent that pins its own model in .vibe/agents/ keeps it. The script format is described
in the Quick start.
Exit codes
| Code | Meaning |
|---|---|
0 | the task ended ready or done, or the run stopped where --dry-run or --until asked |
2 | the run paused for a human: task in review (QA did not approve, escalation, inconclusive review), or a run budget was reached |
1 | the run failed, or the command itself failed (unknown task, bad configuration, plugin or workspace error) |
130 | the run was cancelled (Ctrl-C, or a hook that aborted a phase) |
Cancelling. Ctrl-C cancels the run cooperatively: sessions stop before their next
model call, interrupted subtasks go back to pending, and run.json is saved as
cancelled. A second Ctrl-C exits immediately with code 130, leaving run.json as
running. Either way, vibe run <REF> --resume
continues later.
Resuming. --resume continues the last run from the phase recorded in run.json,
keeping its run id, profile, spec, plan and completed subtasks. The other options still
apply: --from overrides the phase to restart at, --until and --dry-run where to stop. A run that already finished
cannot be resumed (already finished; start a new run, exit 1); start a new one. --from <PHASE> instead starts a new run at that phase,
reusing the artefacts on disk; use it after fixing something by hand, for example
vibe run 3 --from qa. Resume semantics are detailed in
The pipeline.
vibe run 1 # full pipeline
vibe run 1 --dry-run # spec and plan only, then inspect them
vibe run 1 --resume # continue after the dry run
vibe run 1 --complexity trivial # skip spec for a one-line fix
vibe run 1 --model anthropic/claude-opus-5 # one model for every phase
vibe run 1 --provider ollama # local model, provider's default
vibe run 1 --workspace in_place --auto-merge # no worktree, nothing to merge
vibe run 3 --from qa # review again after a manual fix
The run starts with a line naming the task, the build model and the workspace, renders the events live (see the Quick start for a sample), and ends with a table of the phases of this invocation, the final status, duration and tokens, the branch, worktree and task directory, and a hint for the next command.
With --json, vibe run prints the pipeline events as they happen, one JSON object per
line, in the Envelope format of events.jsonl (see
Domain model), then one last object of type summary
with run_id, task_id, final_status, run_status, success, exit_code,
duration_ms, usage, phases, last_error, branch, worktree and task_dir:
vibe run 1 --json | jq -r 'select(.event.type == "phase_finished") | "\(.event.phase): \(.event.summary)"'
vibe approve and vibe reject
vibe approve <REF> [--comment TEXT]
vibe reject <REF> --reason TEXT
Answer the approval a paused run is waiting for (see
Human approvals). The run is not resumed: continue it
with vibe run <REF> --resume. Both fail when the run is not waiting for an approval.
vibe cancel
vibe cancel <REF> [--wait]
Asks the process that runs the task (a vibe run in another terminal, for example) to stop
after its current step, as Ctrl-C would. --wait returns once the run has stopped. It
fails when no process runs the task. Only one process at a time can run a given task: a
second vibe run fails with already being run by another process.
vibe pr
vibe pr <REF> [--repo OWNER/REPO] [--forge github|gitlab] [--remote origin] [--base BRANCH] [--draft] [--no-push]
For a task in ready with the git_worktree or container workspace: pushes its branch to
the remote and opens a pull request (a merge request on GitLab) into the branch the task was
forked from. The description summarises the spec (or the task), lists the subtasks, gives the
last QA verdict and the required checks, and ends with Closes #N when the task was imported
from an issue of the same repository. The repository is read from the remote URL unless
--repo is given; the forge from the remote host (or the imported issue), else GitHub. A
token is required (GITHUB_TOKEN or GITLAB_TOKEN). The URL is added to progress.md.
vibe memory
vibe memory list [--query TEXT]
vibe memory clear [--yes]
The project memory holds lessons learnt by earlier tasks: findings of the spec phase
(conventions), subtasks that failed repeatedly and QA issues that fixes could not solve
(pitfalls). They are kept in .vibe/memory.jsonl, once each, and the ones sharing words with
a new task’s title and description are given to its agents as “Recalled from project”.
list shows them newest first, or the ones relevant to --query; clear forgets them.
Set pipeline.project_memory = false to turn the memory off; plugins can register other
memory stores.
vibe tui
vibe tui
A terminal view of the project with three screens, listed in the title bar: Tasks (T),
Activity (A) and History (H). q quits from any of them; Esc closes what is
open, then goes back to Tasks, and quits only from Tasks (not from its Trace tab).
Tasks is the board: tasks on the left (a ● marks the ones a process is running,
whichever terminal started them), the selected task on the right with its run state, a
budget gauge and four tabs: Activity (phases, agents, tools, validations, approvals, and
the text of the current step as the model writes it), Plan (subtasks and their status),
Changes (the workspace diff summary) and Trace (the tool calls of the last run).
| Key | Action |
|---|---|
↑ ↓ (or k j) | select a task |
n | new task (type the title, Enter) |
r / R | run / resume the selected task |
c | cancel its run (also one started in another terminal) |
a / x | approve / reject what the run waits for (a rejection asks for a reason) |
Tab | next tab |
g | read the tasks again |
q | quit; runs started from the UI are cancelled and stay resumable |
The Trace tab lists the calls of a run, one per line: seq role subtask tool duration exit err (error when the tool failed, timeout, or no result for a call that never
returned). While it is shown, ↑ ↓ move among the calls instead of the tasks (Tab to
another tab to select tasks again); the other task keys keep working.
| Key (Trace tab) | Action |
|---|---|
↑ ↓ | select a call (in an expanded call or its output: scroll) |
[ / ] | previous / next run, when the task has several |
Enter | expand the call: its arguments as JSON and a preview of its output |
o | load its complete output from .vibe/tool-output/ into a scrollable pane (PgUp PgDn); a note says when it was not traced or is gone, and when the call was paired with its result by order (logs from before 0.5) |
Esc | close the output, then collapse the call |
g | load the calls again (they are also loaded each time the tab is entered) |
Activity is the feed of every task’s events, oldest first, each prefixed with the task
number (#003). It opens on the last 200 events and follows new ones.
| Key (Activity) | Action |
|---|---|
↑ ↓ (PgUp PgDn) | select an event; the feed stops following |
f | follow the newest event again, or stop |
1 … 7 | show or hide agents, tools, phases, git, approvals, budget, logs |
Enter | open the event’s task on the Tasks screen |
g | load the last 200 events again (also done each time the screen is entered) |
A log that cannot be read is reported once in the status line; the other tasks keep coming.
History lists the finished tasks (ready and done; a adds failed and cancelled ones) as
vibe history does: number, title, status, runs, commits, changed files, tokens, active time,
cost (- without a [pricing] table), with the notation of vibe history: + marks a lower
bound, ~ an approximate file count; finished is the end of the last run, else the last
activity. Enter opens the task: its runs (state, start, duration, tokens), commits, changed
files (status, where the list comes from, whether it is approximate), validations, last QA
report and errors; ↑ ↓ scroll it and Esc closes it. The list is loaded each time the
screen is entered; g loads it again.
History and trace data are loaded in the background on entry, so the screen stays responsive on large projects (“Loading…” shows meanwhile).
The UI reads the same files and events as the other commands, so vibe run, vibe approve
or vibe cancel in another terminal show up in it. Runs use the project configuration
(there are no --provider or --model flags; set default_model or [phases]). It needs an
interactive terminal.
vibe serve
vibe serve [--bind 127.0.0.1] [--port 7777] [--provider NAME] [--model P/M] [--workspace NAME] [--script FILE] [--evals DIR] [--exit-on-stdin-eof]
Serves an HTTP API and a web UI for the project. It prints the address and a link that
carries the access token (http://127.0.0.1:7777/#token=…); open it in a browser. The web
UI shows the task board, the selected task with its run, budget, a live activity feed with
the text as the model writes it, the plan, spec, QA reports, tool-call trace and workspace
changes, and lets you create, run, resume and cancel tasks and approve or reject what a run
waits for. Its views:
- Tasks: the board and the selected task. Its Trace tab lists every tool call of a
run (role, subtask, tool, duration, exit code, a badge for errors and for calls logged
before 0.5 that were
paired by order); a call expands to its arguments as JSON and its output preview, and Load complete output fetches the whole traced output. A selector picks the run when there are several; the footer counts calls and errors and lists the files written. - Activity: every task’s events as they happen, with toggles per group of event types (agents, tools, phases, git, approvals, budget, logs), a task filter and a pause button. Clicking a line opens its task.
- History: what each finished task delivered (the columns of
vibe history), with failed and cancelled tasks on demand; a row expands to its runs, commits, changed files (with their source and whether the list is approximate), validations, last QA verdict and problems. - Evaluations: with
--evals DIR, everysummary.jsonthatevals/run_suite.pywrote underDIR(success rate, time, tokens per case and model).
The page reads one event stream, GET /api/stream, for the whole project; it resumes from
the last event id when the connection comes back, and polls the task list every 10 s
(every 2 s while the stream is down) since creating a task is not an event.
Security: the server listens on the loopback interface by default and then only accepts its
own host names (127.0.0.1, localhost, [::1]), which blocks DNS rebinding. Every API
call needs Authorization: Bearer <token>; the token is random per start, printed once and
written to .vibe/server.token (owner-only on Unix), and removed on exit. The two event
streams, /api/stream and /api/tasks/{ref}/stream, also accept ?token=, because browsers
cannot set headers on them; no other route does. The API never returns the
configuration or keys. --bind with another address prints a warning: anyone who reaches it
with the token controls the agents. Ctrl-C stops the server and cancels the runs it started
(they stay resumable).
--exit-on-stdin-eof makes the end of standard input stop the server exactly like Ctrl-C
(runs cancelled, token file removed, exit code 0). An application that starts vibe serve as
a child with a pipe on its standard input (the macOS app does) uses it so that the server
does not outlive it, even when the application is killed or crashes. Without the flag, standard input is not
read.
| Method and path | Effect |
|---|---|
GET /api/health | name and version |
GET /api/tasks | every task with its number, whether it runs, and its last run’s status, phase and pending approval |
POST /api/tasks {"title", "description"} | create a task |
GET /api/tasks/{ref} | task, run state, spec, plan and QA reports |
POST /api/tasks/{ref}/run {"resume": bool} | start or resume a run (202) |
POST /api/tasks/{ref}/cancel | stop its run, from this server or another process |
POST /api/tasks/{ref}/approve {"comment"} | approve what the run waits for |
POST /api/tasks/{ref}/reject {"reason"} | reject it |
GET /api/tasks/{ref}/changes | workspace changes summary |
GET /api/tasks/{ref}/events?after=SEQ&all=bool | logged events |
GET /api/evals | evaluation summaries found under --evals |
GET /api/tasks/{ref}/stream?after=SEQ | server-sent events: logged events after SEQ, then new ones and streamed text; each SSE event is named by its type and carries the envelope, with the sequence number as id |
GET /api/events?after=CURSOR&since=TIME&type=T&task=REF&limit=N | logged events of every task as tagged envelopes ({"task", "number", "schema", "seq", "at", "event"}), oldest first, the last N (default 1000, at most 10000) after the filters; see below |
GET /api/stream?after=CURSOR&since=TIME&type=T&task=REF | server-sent events of every task: logged events after the starting point, then new ones (tasks created later included) and the streamed text of runs this server started; see below |
GET /api/history?all=bool | history of every finished task (vibe history --json); all adds failed and cancelled ones |
GET /api/history/{ref} | history of one task, finished or not (vibe history REF --json) |
GET /api/tasks/{ref}/trace?run=RUN&all=bool | tool calls of the last run, of the run whose id starts with RUN, or of every run with all (a list), as vibe trace --json; 404 when the task has not been run (where vibe trace --json prints null or []) or no run matches RUN, 400 when RUN matches several runs or comes with all |
GET /api/tasks/{ref}/trace/{call}/output | complete output of a call, as text/plain, cut at 8 MiB (then marked at the end and with X-Vibe-Truncated: true); 400 for an id that is not 12 hex digits, 404 when the call is unknown or its output was not traced, is gone or cannot be read |
{ref} is a task number, directory name or id prefix, as on the command line; a prefix
that matches several tasks is a 400. Errors are {"error": "…"} with 400 (including an
unknown event type, a malformed cursor, time or limit in /api/events and /api/stream),
401, 403, 404, 409, or 503 when too many event streams are open.
Project-wide events are ordered by an event cursor, <nanoseconds since the epoch>-<task number>-<seq> (1790000000123456789-3-17): by time, then task, then sequence
number, so events of two tasks logged at the same instant are neither lost nor repeated on
resume. In /api/events and /api/stream, after=CURSOR keeps the events after it and
since=TIME those after that time (RFC 3339, or an age such as 30m, 2h, 1d, as
vibe events --since); type (an event type) and task (a task
reference) repeat, and each list keeps events that match one of its values. /api/events
answers with two headers: X-Vibe-Cursor, the cursor of the last event read before the
filters (the value to pass as after to go on from there, absent when nothing was read),
and X-Vibe-Read-Errors, the number of task logs that could not be read (their events are
missing; the server logs why). In /api/stream, each SSE event is named by its type and
its data is the tagged envelope; logged events carry their cursor as id, and a client that
reconnects with Last-Event-ID resumes after it (the header wins over after). Streamed
text (agent_delta) has no id and is not replayed. Without after, since or
Last-Event-ID, the stream starts from now. A comment line is sent every 15 s to keep an
idle connection open. All streams share one reader of the logs, which runs while at least
one is open; at most 32 streams are open at once (503 beyond).
Cursor order is the order of the at times each process writes. Events of one task always
come in order, but a vibe run in another process whose clock reads earlier than the
server’s can log an event with a cursor below one already sent for another task. An open
stream still sends it, once, so ids are not always increasing; but a client that was
disconnected while it was logged and resumes from its last id does not get it, since it
sorts before that id. Reload the events of a task (/api/tasks/{ref}/events) when its
state must be exact.
Trace outputs are only read from .vibe/tool-output/: paths recorded elsewhere are
ignored, and a file there that is a link leading out of it is answered like a missing
output (404, the reason in the server’s log).
vibe events
vibe events <REF> [--after SEQ] [--follow | --all] [--since TIME] [--type TYPE]...
vibe events [--follow] [--since TIME] [--type TYPE]... [--task REF]...
With a <REF>, replays the logged events of the task’s last run, rendered like vibe run
(or as JSON envelopes with --json). --after SEQ skips events up to that sequence number,
--follow keeps printing new events until the run ends (use it from another terminal while
vibe run works), and --all shows every run of the task.
Without <REF>, it prints the activity of every task, merged in time order, each line
prefixed with the task number (#3 ✓ plan: …). --follow prints the events logged from
now on, including those of tasks created meanwhile, until Ctrl-C (exit code 0); with
--since, it first prints what was logged since then. With --json, each line is an
envelope carrying its task: {"task": "<id>", "number": 3, "schema": 2, "seq": …, "at": …, "event": {…}}. A task whose log cannot be read is reported once on standard error and the
feed goes on with the others.
| Option | Effect |
|---|---|
--since TIME | only events logged after TIME: RFC 3339 (2026-09-27T10:00:00Z) or an age (90s, 30m, 2h, 1d) |
--type TYPE | only events of this type (repeatable), e.g. --type run_finished --type committed; an unknown type is an error |
--task REF | only the events of this task (repeatable); with <REF> as well, nothing is printed unless <REF> is one of them |
--after and --all need a <REF>. With --follow and a <REF>, the end of the run stops
the command even when --type or --since hide the run_finished event; a run that paused
and was resumed is followed to its new end. As with vibe run, some events are only
rendered with -v (agent text, committed, artefact_written, subtask integrations without
conflicts, informational logs): add -v, or use --json,
to see them when you filter on them. The envelope and
every event type are described in Events.
vibe history
vibe history [--all]
vibe history <REF>
What finished tasks did, read from their event logs, run.json and git (nothing is
recomputed by a model). History of finished work explains each figure and
where it comes from. Without <REF>, a table of the ready and done tasks, most
recent first — --all adds failed and cancelled ones:
# title status runs commits files tokens active cost finished
3 Add OAuth login done 2 5 7 184.2k 6 min 12 s 1.84 USD 2 h ago
1 Fix typo in README ready 1 1 1 2.9k 4.1 s - 1 d ago
tokens counts input plus output tokens of every run, active the time the runs were
working (pauses excluded); a + on them marks a lower bound: some run was logged before 0.5
(its totals are unknown) or did not finish. cost is - without a
[pricing] table, or when a model the task used has no price in
it; a + on it marks a lower bound: some tokens were used outside a finished agent session
(a session cut by a crash, or calls such as the repair of an invalid structured answer) and
are not priced.
files is marked ~ when the list is approximate.
With <REF> (any task, finished or not), the detail:
- the task, its branch (the one its run recorded, or the task branch git still has) and its totals;
- one block per run: its state (
running,finished, orinterruptedwhen the process died), when it started, how many times it was resumed, its active time, tokens and cost, its phases with their result, the merge, a pending approval and the last error; - the commits (short sha, first line of the message, number of files, subtask);
- the changed files with their status (
A,M,D,R,C,?when unknown) and where the list comes from: the recorded merge, the diff of the task branch against its base, the commit events, or the files the agents wrote (the last two are marked approximate); - the validation commands of the last run that ran them, and whether they passed;
- the last QA verdict and its summary;
- problems met while reading (a missing merge commit, an unreadable file).
With --json, the output is the TaskHistory document (an array of them without <REF>):
task, number, runs (with state, usage, active_ms, totals_known, phases,
commits, merged, validations, approvals, last_error), totals, changed_files
(source, approximate, files), last_qa, validations_passed, cost,
last_activity and errors.
vibe trace
vibe trace <REF> [--run RUN | --all] [--tool NAME] [--subtask ID] [--full]
The tool calls of the task’s last run (--run picks another by id or id prefix, --all
shows every run), in the order they were made. Tool call trace describes the
trace store, its disk usage and what it may contain. Each call is a block:
#4 coder · subtask 1/2 Write hello.txt · write_file 2 ms ok [call 5f0c2a9e1b7d]
{
"content": "Hello, world!\n",
"path": "hello.txt"
}
⟵ output
Created `hello.txt` (14 bytes).
The number is the call’s position in the run (it stays the same when filters hide other
calls), then the agent role, the subtask, the tool, the duration and the result: ok,
exit N for commands, error, timed out, or no result for a call that never returned
(the run was interrupted). The call id pairs the call with its result; logs recorded before
0.5 have none (-) and are paired by order, which is shown as paired: by order. Errors
are shown in red.
The arguments are printed as JSON, with strings longer than 2 000 characters cut. The
output is the preview logged with the event; --full prints the complete arguments and the
complete output from the trace store (.vibe/tool-output/, see
pipeline.trace_outputs), or says why it is not there: tracing was off
or the run predates 0.5, or the file was removed (vibe task discard, manual cleanup). A
footer counts the calls and the errors and lists the files written by write_file and
edit_file.
--tool and --subtask (id or id prefix) keep only the matching calls. With --json, the
output is the RunTrace document — run, calls (each with run, call, role,
subtask, tool, input, called_at, returned_at, duration_ms, is_error,
exit_code, timed_out, preview, output_chars, output_file, paired) and
files_written — or an array of them with --all; --full does not change it (read
output_file for the complete output). A task that has not been run prints Task has not been run yet.
(null, or [] with --all, with --json).
vibe status
vibe status
Project summary: the number of tasks per status, a table of recent runs (task, run status, phase, last update) and the active task worktrees.
vibe config
vibe config show [--default]
vibe config path
vibe config set <KEY> <VALUE>
| Subcommand | Effect |
|---|---|
show | prints the effective configuration as TOML (file merged with defaults) |
show --default | prints the built-in defaults |
path | prints the path of .vibe/config.toml |
set <KEY> <VALUE> | sets a dotted key; the value is a TOML literal (true, 3, ["a", "b"]) or else a plain string |
vibe config set pipeline.auto_merge true
vibe config set phases.plan.model anthropic/claude-opus-5
vibe config set security.blocked_commands '["docker"]'
vibe agents
vibe agents list
vibe agents show <ROLE>
vibe agents export <ROLE> [--dir <DIR>] [--force]
| Subcommand | Effect |
|---|---|
list | every agent with its tools, thinking level, model, source and description; the source is builtin, override (a built-in changed by .vibe/agents/) or custom |
show <ROLE> | the system prompt of the agent after overrides (the whole AgentSpec with --json) |
export <ROLE> | writes <role>.toml with every setting and <role>.md with the prompt into .vibe/agents (or --dir); refuses to overwrite either file without --force |
These commands read the built-in agents and .vibe/agents/; they do not start plugins, so
agents contributed by plugins are not listed. An invalid override file makes them fail with
the file name, which makes vibe agents list a quick check of your overrides. Roles and
override files are described in Customising agents.
vibe plugins
vibe plugins list
vibe plugins check
list shows the declared and discovered plugins with name, source (config for
[[plugins]] entries, manifest for .vibe/plugins/ and the user directory), whether they
are required, their declared capabilities and command. It starts nothing. check starts
every plugin, prints its version, the capabilities from its handshake and its tools, or the
error, then stops it; it exits with 1 when any plugin failed. Use it to debug a plugin before
a run. See Plugins.
vibe doctor
vibe doctor
Checks the environment and the configuration and prints one line per check, marked ✓
(ok), ! (warning) or ✗ (failure):
| Check | Fails when |
|---|---|
git | git --version does not run |
config | .vibe/config.toml does not parse (a missing file is only a warning) |
repository | the project is not a git repository and the workspace is git_worktree |
providers / provider | the provider table is invalid, a provider used by default_provider, default_model or a [phases.*] model is not declared, or a used provider has no key (an unused one only warns) |
plugin | a plugin with required = true does not start (an optional one only warns) |
workspace | pipeline.workspace is neither built in nor possibly provided by a plugin |
storage | .vibe/ (or the project directory) is not writable |
The exit code is 1 when any check fails, 0 otherwise; --json prints
{"ok": …, "checks": [{"level", "name", "message"}]}. Agent files are not checked here;
vibe agents list reports an invalid one. Run vibe doctor after vibe init and whenever
a run fails before its first phase.
vibe completions
vibe completions <SHELL>
Prints a completion script for bash, zsh, fish, elvish or powershell. Installation
is shown in Installation.
Exit codes
Every command exits with 0 when it did what was asked and 1 when it failed: an unknown
task reference or run, a bad configuration, a storage or git error, a --run or
--subtask prefix that matches nothing or several ids. A usage error (an unknown option,
an invalid value such as an unknown --type, conflicting options) exits with 2 before
anything runs. vibe run has its own codes (above): 2 there also means the run paused for
a human, and 130 that it was cancelled. vibe doctor and vibe plugins check exit with
1 when a check fails. vibe history, vibe trace, vibe events and JSON documents exit with
0 when their output is closed early (vibe history | head).
JSON output
--json replaces human-oriented output with one JSON document on standard output, suitable
for scripts; logs still go to standard error. Field names follow the persisted types
(Domain model) and statuses are the snake_case names listed
above. task add prints number, id, dir and task; task list an array of
{number, task}; task show the number, task, dir, spec, plan, qa_reports,
run and worktree (branch, path and whether each exists); history the TaskHistory
documents and trace the RunTrace documents described above; init the paths it wrote.
vibe run --json and vibe events --json are the exceptions: a stream of events, one per
line (then the summary for vibe run).
History of finished work
vibe history answers “what did the agents deliver, and what did it cost?” for every task,
long after the run that did the work. Nothing is asked of a model and nothing extra is
stored: the history is rebuilt each time from what the runs already left in .vibe/ and in
git (ADR-008).
The table
vibe history # ready and done tasks, most recent first
vibe history --all # failed and cancelled tasks too
# title status runs commits files tokens active cost finished
2 Write the greeting module done 1 1 1 6.7k 251 ms - just now
1 Fix typo in README done 1 0 0 2.9k 187 ms - just now
| Column | Meaning |
|---|---|
runs | runs of the task; a run and its resumes count once |
commits | commits the pipeline made for the task (checkpoints, subtasks, fixes, integrations) |
files | files the task changed, ~ when the list is approximate (see below) |
tokens | input plus output tokens of every run, resumes included |
active | time the runs were working, pauses excluded |
cost | money spent, only with a [pricing] table; - otherwise |
finished | end of the last run, else the last activity |
A + after tokens, active or cost marks a lower bound: a run was logged before 0.5,
whose totals are unknown, or has not finished, or some tokens were used outside a finished
agent session and are not priced. A legend under the table repeats this when it applies.
One task in detail
vibe history 2
#2 Write the greeting module
status done
branch vibe/write-the-greeting-module-6fed323c
tokens 6.3k in / 365 out
active 251 ms
activity just now
Runs (1)
4ef9f554 finished → done, started just now
251 ms active, 6.3k in / 365 out tokens
phases assess ✓ spec ✓ plan ✓ build ✓ qa ✓ merge ✓
merged vibe/write-the-greeting-module-6fed323c into main (62a82acb)
Commits (1)
62a82acb vibe: complete subtask 1 - Implement the task (1 file(s)) · subtask 1/1 Implement the task
Files (1) from merge 62a82acb (fast-forward)
A hello.txt
QA round 1: approved, 0 issue(s)
Approved by the mock provider.
Any task can be shown, finished or not. Each run shows its state: finished, running
when a process holds the task’s run lock, or interrupted when the process died before the
run ended (vibe run <REF> --resume continues it). The detail ends with the validation
commands of the last run that ran them, the last QA verdict, and the problems met while
reading (a merge commit git no longer has, a file that does not parse), if any.
Where the data comes from
| Part | Source |
|---|---|
| runs, phases, approvals, errors | the run_started … run_finished events of events.jsonl; run.json completes the last run |
| tokens, active time | the totals of each run’s last run_finished (resumes included); run.json for the last run when its log predates 0.5 or it has not finished |
| commits | the committed events (and subtask_integrated for older logs) |
| merge | the merged event |
| validations | the validation_finished events |
| QA verdict | the last qa_report_<n>.json |
| changed files | git, else the events (below) |
| cost | the agent_started model and agent_finished usage of each session, and [pricing] |
Changed files and the approximate marker
The list of changed files comes from the most exact source available, and the detail says
which one it used (from …):
- the recorded merge (
merge <sha>): the files the merge brought into the base branch. A fast-forward is compared from where the task’s first recorded commit started; - the task branch (
diff of <branch> against <base>), while it exists and differs from its base; - the commit events (
commit events): every file of everycommittedevent, statuses unknown; - the files written by the agents (
files written by the agents): paths passed towrite_fileandedit_filecalls that succeeded; nothing recorded.
Sources 3 and 4 are approximate: they can list a file a later commit reverted, and the
fourth misses files that shell commands wrote. The first is approximate in one case: a
fast-forward that contains none of the task’s recorded commits. Approximate lists are
marked ~ in the table and approximate in the detail. Statuses are A added, M
modified, D deleted, R renamed, C copied, ? unknown. The rules are detailed in
Persistence layout.
A project outside git, or a task run with the in_place workspace, has no branch to diff:
its files come from the events.
Tokens, time and cost
Tokens and active time are always shown. A money cost is shown only when you give prices:
[pricing."anthropic/claude-sonnet-5"]
input = 3.0
output = 15.0
cache_read = 0.3
cache_write = 3.75
The cost of a task is computed only when every agent session it ran used a model with a
price that covers its tokens; otherwise it is -, never an estimate. Matching rules and the
cases that make a cost a lower bound are in [pricing].
JSON and other interfaces
vibe --json history prints an array of TaskHistory documents, vibe --json history <REF>
one of them; the fields are listed in the
command line reference. The same documents are served by
GET /api/history?all=bool and GET /api/history/{ref} (vibe serve),
shown by the History view of the web UI (a table whose rows expand to the detail) and by
the History screen of vibe tui (H). Clients of vibe serve, such as the macOS app
in apps/macos/VibeFactory,
read the same routes.
To see how a run got there, call by call, use vibe trace.
Tool call trace
vibe trace shows how a run did its work: every tool call an agent made, with its complete
arguments, how long it took, how it ended, and its output. It is the answer to “which
command failed, and what did it print?” once the live output of vibe run is gone.
Reading a trace
vibe trace 2 # the last run of task 2
vibe trace 2 --full # with complete arguments and outputs
vibe trace 2 --tool bash # only the shell commands
vibe trace 2 --all # every run of the task
vibe trace 2 --run 4ef9 # one run, by id or id prefix
vibe trace 2 --subtask 7c1e # the calls made for one subtask
── run 4ef9f554 ──────────
#1 coder · subtask 1/1 Implement the task · write_file 1 ms ok [call 2e5c7f564a52]
{
"content": "Hello, world!\n",
"path": "hello.txt"
}
⟵ output
Created `hello.txt` (14 bytes).
#2 coder · subtask 1/1 Implement the task · bash 7 ms error, exit 1 [call 4b5fd969429a]
{
"command": "cat hello.txt; ls nope"
}
⟵ output
Hello, world!
ls: nope: No such file or directory
Exit code: 1
2 call(s), 1 error(s)
files written: hello.txt
Each block starts with the call’s position in the run, the agent role, the subtask (its
position and title in the plan), the tool, the duration and the result: ok, exit N for a
command, error, timed out, or no result when the run was interrupted before the tool
returned. The call id at the end pairs the call with its result. The footer counts the calls
and errors and lists the files written by write_file and edit_file. The options are
described in the command line reference.
Without --full, the output is the 200-character preview logged with the event, and
strings longer than 2 000 characters in the arguments are cut. --full reads the complete
output from the trace store and prints the arguments whole.
The trace store
While a run works, the complete output of every tool call is written to
.vibe/tool-output/<task dir>/<run>/<call>.txt
at the project root, for example
.vibe/tool-output/002-write-the-greeting-module/4ef9f554-…/4b5fd969429a.txt. The
tool_returned event records that path (output_file) and the length of the complete
output (output_chars), so the log stays small and the output stays one read away
(Events).
Two settings control it ([pipeline]):
| Key | Default | Effect |
|---|---|---|
trace_outputs | true | write the store; false keeps only the previews in the log |
trace_max_chars | 100000 | characters kept per call; a longer output is cut and ends with [vibe: output cut to the first N of M characters] |
The store is independent of what the model sees. An output longer than the runner’s limit
is still truncated for the model, and its complete text is also written in the workspace
(<workspace>/.vibe/tool-output/<uuid>.txt) where the agent can read it, so such outputs
exist twice.
Disk usage
The store grows with what the tools print: at most trace_max_chars characters per call,
usually far less (a write_file result is one line; a test suite’s output can reach the
cap). Nothing is removed automatically. To see and reclaim the space:
du -sh .vibe/tool-output/*/ # per task
rm -rf .vibe/tool-output/002-write-the-greeting-module/<run> # one run's outputs
vibe task discard 2 --yes # the task, its workspace and its outputs
Removing files only affects --full and the complete output in the other interfaces:
vibe trace then says the output file is missing and shows the preview. Lower
trace_max_chars to keep less per call, or set trace_outputs = false to stop writing the
store.
Privacy
Tool outputs can contain secrets: a command that prints an environment variable, a
configuration file an agent read, a token in an error message. The trace store stays on
your machine: vibe init puts tool-output/ in .vibe/.gitignore, so it is never
committed, and nothing sends it anywhere.
Two things are worth knowing:
- the event log,
.vibe/tasks/*/events.jsonl, is committed with the task by default (tasks/is project history on purpose), and it holds each call’s complete arguments and the 200-character preview of its output. Addtasks/*/events.jsonlto.vibe/.gitignoreif that is too much to share (What to commit); vibe servereturns complete outputs (GET /api/tasks/{ref}/trace/{call}/output) to anyone holding its token. It listens on the loopback interface by default; with another--bindaddress, anyone who can reach it with the token can read them.
Logs recorded before 0.5
Runs logged by an earlier version have no call ids and no stored outputs. vibe trace
pairs their calls with their results by order, per role, tool and subtask, and shows
paired: by order; --full reports that the output was not traced and shows the preview.
JSON and other interfaces
vibe --json trace <REF> prints the RunTrace document (an array of them with --all):
run, calls and files_written, each call with its arguments, times, result, preview,
output_chars and output_file. --full does not change it; read output_file for the
complete output. The same documents are served by GET /api/tasks/{ref}/trace and the
complete output by GET /api/tasks/{ref}/trace/{call}/output
(vibe serve). The web UI shows them in the Trace tab of a task
(a call expands to its arguments and output; Load complete output fetches the whole
text), and vibe tui in the Trace tab of the task detail (Enter expands a call, o
loads its complete output).
For what the task delivered in the end (commits, changed files, tokens, cost), see History.
Providers and models
A provider is a model back-end: Anthropic, any API speaking the OpenAI Chat Completions
dialect (OpenAI, Groq, Mistral, xAI, OpenRouter, Ollama, most gateways), or the built-in
mock. You declare providers in .vibe/config.toml, give each a name, and refer to models as
<provider name>/<model id>.
Defaults
Without any configuration, Vibe Factory knows three providers and uses
anthropic/claude-sonnet-5 for every phase with the medium thinking level:
default_provider = "anthropic"
default_model = "anthropic/claude-sonnet-5"
[providers.anthropic]
kind = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
[providers.openai]
kind = "openai"
api_key_env = "OPENAI_API_KEY"
[providers.ollama]
kind = "openai"
base_url = "http://localhost:11434/v1"
default_model = "qwen2.5-coder"
These built-in values apply when .vibe/config.toml does not exist and when the file
contains no [providers.*] table at all. As soon as you declare one provider, the providers
are exactly the tables you wrote: declare every provider you use (a file written from the
defaults, as above, already contains the three).
A provider whose key is missing still loads. It fails with an authentication error on its first call only, so listing providers you do not use is harmless.
Provider keys
Every [providers.<name>] table accepts:
| Key | Type | Meaning |
|---|---|---|
kind | string, required | anthropic, openai, ollama or mock (plugins may add kinds) |
api_key | string | the key itself; avoid committing it, prefer api_key_env |
api_key_env | string | environment variable holding the key (an empty value counts as missing) |
base_url | string | endpoint override (gateway, proxy, self-hosted server); trailing / is ignored |
default_model | string | model used when a reference names no model, or names default |
stream | bool | stream answers as they are generated (default true); set false for a gateway that mishandles server-sent events |
extra | table | kind-specific settings, below |
api_key wins over api_key_env when both are set.
An Anthropic API key that is not scoped to a workspace is refused with HTTP 400 (“must include
the anthropic-workspace-id header”). Set ANTHROPIC_WORKSPACE_ID to the workspace id
(wrkspc_…, from the console’s workspace settings) and every anthropic provider sends it as
anthropic-workspace-id, or use a key created in a workspace. In the macOS app, add the
variable in Settings like the key.
extra keys
| Key | Kinds | Type | Effect |
|---|---|---|---|
headers | anthropic, openai, ollama | table of strings | extra HTTP headers sent with every request |
thinking | anthropic | "adaptive" or "budget" | force the thinking form instead of choosing it from the model id (see Thinking levels) |
auth | anthropic | "api_key" (default) or "bearer" | bearer sends the key as Authorization: Bearer with the OAuth beta header instead of x-api-key |
require_api_key | openai, ollama | bool | default false when base_url is set, true on the official OpenAI endpoint; with false, no Authorization header is sent when the key is missing |
max_tokens_param | openai, ollama | string | name of the output-budget field; default max_completion_tokens on the official endpoint, max_tokens elsewhere |
reasoning | openai, ollama | bool | send reasoning_effort; default on for the official endpoint only, since many compatible servers reject unknown fields |
reply | mock | string | fixed answer of the mock provider (default "Mock response.") |
The ollama kind is the openai kind with base_url defaulting to
http://localhost:11434/v1.
[providers.gateway]
kind = "openai"
base_url = "https://llm.internal.example.com/v1"
api_key_env = "GATEWAY_TOKEN"
[providers.gateway.extra]
max_tokens_param = "max_tokens"
[providers.gateway.extra.headers]
"X-Team" = "platform"
Referring to models
A model reference is provider/model, where provider is the name of a
[providers.<name>] table, not its kind. A reference without / is attributed to
default_provider.
| Reference | Resolves to |
|---|---|
anthropic/claude-opus-5 | provider anthropic, model claude-opus-5 |
claude-opus-5 | provider default_provider, model claude-opus-5 |
ollama/default | provider ollama, its default_model |
openrouter/meta-llama/llama-4-maverick | provider openrouter, model meta-llama/llama-4-maverick (split at the first /) |
On providers of kind anthropic these shorthands are expanded:
| Shorthand | Model id |
|---|---|
opus | claude-opus-5 |
sonnet | claude-sonnet-5 |
haiku | claude-haiku-4-5-20251001 |
fable | claude-fable-5-1 |
An unknown provider name fails with unknown provider `x` for model `x/y` (configured: …),
and an unknown kind with provider `x` has unknown kind `y` (known kinds: anthropic, mock, ollama, openai).
Models per phase
Each pipeline phase can use its own model and, optionally, its own thinking level. Phases
without an entry use default_model, and every agent keeps its own thinking level.
default_model = "anthropic/sonnet"
[phases.assess]
model = "anthropic/haiku"
thinking = "low"
[phases.plan]
model = "anthropic/opus"
thinking = "high"
[phases.build]
model = "ollama/qwen2.5-coder"
thinking = "off"
The phase names are assess, spec, plan, build, qa, fix and merge. An agent can
also pin a model regardless of the phase; see Customising agents.
Thinking levels
| Level | Budget | Anthropic, current models | Anthropic, older models | OpenAI-compatible |
|---|---|---|---|---|
off | none | no thinking | no thinking | no reasoning_effort |
low | 1 024 tokens | adaptive, effort low | budget_tokens: 1024 | reasoning_effort = "low" |
medium | 4 096 | adaptive, effort medium | 4 096 | "medium" |
high | 16 384 | adaptive, effort high | 16 384 | "high" |
max | 32 768 | adaptive, effort max | 32 768 | "high" |
On OpenAI-compatible servers the level is only sent when reasoning is enabled (see
above); otherwise it is ignored.
With Anthropic, current models (Claude 4.6 and later: Sonnet 5, Opus 5 and 5.5, Fable, Opus
4.6 to 4.8, Sonnet 4.6) take thinking: {type: "adaptive"} with output_config.effort and
reject budget_tokens and sampling parameters, so vibe never sends a temperature to them.
Older models (Haiku 4.5, Claude 4.5 and before) and unknown model ids keep
budget_tokens, and thinking removes the temperature as the API requires. The form is
chosen from the model id; set extra.thinking = "adaptive" or "budget" on the provider to
force it, for example behind a gateway that renames models. Model ids are sent as is, so the
choice follows claude-... names.
Recipes
Ollama (local, no key):
[providers.ollama]
kind = "ollama"
default_model = "qwen2.5-coder:32b"
Groq:
[providers.groq]
kind = "openai"
base_url = "https://api.groq.com/openai/v1"
api_key_env = "GROQ_API_KEY"
extra = { require_api_key = true }
OpenRouter:
[providers.openrouter]
kind = "openai"
base_url = "https://openrouter.ai/api/v1"
api_key_env = "OPENROUTER_API_KEY"
default_model = "anthropic/claude-sonnet-5"
[providers.openrouter.extra]
require_api_key = true
[providers.openrouter.extra.headers]
"X-Title" = "vibe-factory"
Reference it as openrouter/anthropic/claude-sonnet-5.
xAI:
[providers.xai]
kind = "openai"
base_url = "https://api.x.ai/v1"
api_key_env = "XAI_API_KEY"
extra = { require_api_key = true }
Mistral:
[providers.mistral]
kind = "openai"
base_url = "https://api.mistral.ai/v1"
api_key_env = "MISTRAL_API_KEY"
extra = { require_api_key = true }
Setting require_api_key = true on hosted services turns a forgotten environment variable
into a clear no API key configured error instead of an anonymous request rejected by the
server.
Mock (dry runs and tests, no network):
default_model = "fake/any"
[providers.fake]
kind = "mock"
extra = { reply = "Nothing to do." }
Streaming
Anthropic and OpenAI-compatible providers stream their answers: the text and, when the
model exposes it, the reasoning arrive as agent_delta events while a step is being
generated (vibe --json run prints them; the terminal output shows the complete text at
the end of each step, as before). The final answer is identical to a non-streamed one.
A request that fails before any text arrived is retried as usual. A stream that breaks after
text was sent is not retried by the provider (the text would be repeated): the step fails
and the agent runtime’s own retry policy applies. An OpenAI-compatible server that refuses
stream or stream_options with a 4xx error gets the same request without streaming.
agent_delta events are not written to events.jsonl.
Retries and errors
HTTP providers retry transient failures on their own: four attempts in total, starting at
1 second and doubling up to 60 seconds, with ±25 % jitter. A Retry-After header from the
server replaces the computed delay, still capped at 60 seconds. On top of that, the agent
runtime retries a still-failing call twice more and publishes a retrying event you will
see in the live output.
| Situation | Classified as | Retried |
|---|---|---|
| HTTP 429 or “rate limit” wording | rate limited | yes |
| HTTP 5xx, including 529 overloaded | server error | yes |
| DNS, TLS, connection or timeout failure, HTTP 408 | network | yes |
| HTTP 401 / 403 | authentication failed | no |
| billing, credit or quota wording, or HTTP 402 (even on a 429) | invalid request | no |
| HTTP 400 / 413 mentioning the context length | context too long | no; the agent session stops and may continue from a summary |
| other 4xx | invalid request | no |
Messages you may see:
AuthFailed: no API key configured for provider `anthropic` (set `api_key` or `api_key_env`)
AuthFailed: HTTP 401: invalid x-api-key sk-***
InvalidRequest: HTTP 429: You exceeded your current quota
RateLimited: HTTP 429: rate limit reached for requests
ServerError: malformed provider response: no choices
Network: request timed out: …
Keys, bearer tokens and token= / api_key= values are masked before anything is logged or
shown. Requests time out after 30 seconds to connect and 300 seconds overall.
Prompt caching
With Anthropic, the system prompt is sent as a cached block, so the many turns of one agent session reuse it; cache reads and writes are reported in the token usage. OpenAI-compatible servers that cache automatically report cached prompt tokens the same way. Nothing needs to be configured.
Tools and security
Agents act on your project through tools. Every tool is confined to the task’s
workspace and governed by a security policy you control from .vibe/config.toml.
The built-in tools
| Tool | What it does | Needs |
|---|---|---|
read_file | Read a text file with line numbers (offset, limit, default 2000 lines). Binary files are refused. | read |
write_file | Create or overwrite a file, creating parent directories. | write |
edit_file | Replace an exact snippet (old_string → new_string, optional replace_all); returns a unified diff. | read + write |
list_dir | Indented tree of a directory (depth 1–10, default 2), honouring .gitignore, skipping .git, target, node_modules. | read |
glob | Find files by pattern (**/*.rs), newest first, at most 2000 results. | read |
grep | Regex search in text files (content, files or count mode, context lines, case-insensitive). | read |
bash | Run a shell command inside the workspace with a timeout (default 120 s, max 600 s). Background jobs are killed when the command finishes. | execute |
web_fetch | Fetch a public http(s) page and return its text (HTML converted, 2 MB read at most, 30 000 characters returned). Hosts resolving to loopback, private, link-local or other internal addresses are refused, and each redirect is checked again; security.web_allowed_domains narrows it to some domains. | network |
web_search | Search through the SearXNG-compatible endpoint of security.search_url and list the first 10 results. Only registered when that key is set. | network |
The web tools refuse to run unless security.allow_network = true. They are read-only
tools, so read-only agents such as the researcher and the reviewers get them too.
Which tools an agent may use is decided per role (see Customising agents): the planner and the reviewers of the spec phase are read-only, the coder and the QA agents have everything.
Path containment
Every path an agent gives is resolved against the workspace root and must stay inside it:
../secrets, absolute paths and symbolic links that point outside are refused;- a symbolic link whose target does not exist yet is refused as well, so a write can never create a file outside the workspace;
- writing tools additionally refuse to write through any symbolic link.
Paths listed in security.extra_read_paths may be read but never written. Nothing under a
.git directory can be written by the file tools, whatever the path used to reach it.
The shell policy
The bash tool does not run a command before the security policy has approved it.
The command line is parsed into segments (pipes, &&, ||, ;, &, subshells,
bash -c "…", eval, find -exec, xargs) and every segment is checked. Anything
the parser cannot understand is denied.
Always blocked
System, disk, privilege and network administration:
shutdown reboot halt poweroff init mkfs fdisk parted gdisk dd sudo su doas chown
iptables ip6tables nft ufw nmap systemctl service crontab mount umount
useradd userdel usermod groupadd groupdel passwd visudo
Program names computed at run time ($CMD, $(…), globs, brace expansion) are denied,
and so are shells reading their program from standard input (echo x | sh).
Guarded commands
| Command | Rule |
|---|---|
rm, rmdir, unlink, shred | No --no-preserve-root; no target containing $, a backtick, *, ?, [, { or ~ (so rm -rf ${HOME%/}, rm *.log and rm -rf /U* are all refused); no ., .., the workspace root itself, or any absolute path outside the workspace (/Users/you/.ssh included). Symbolic links leading out of the workspace are followed for the check. |
find … -delete / -exec rm | The start paths obey the rm rules. |
… | xargs rm | Every upstream command’s path arguments obey the rm rules; xargs must name its program explicitly and cannot take it from its input. |
Redirections >, >>, &>, tee | The target file must be inside the workspace and contain no expansion; /dev/null and friends are fine; /dev/tcp needs network access. |
cd, pushd | The target must stay inside the workspace (no cd .. from the root, no cd -, no CDPATH). |
chmod | No setuid/setgid bits (4xxx, 2xxx, 6xxx, +s). |
git | git config only for reads (get, list, --get*, -l); no -c overrides of identity, hooks, pagers, editors, includes, filters, credential or GPG helpers; no GIT_* environment overrides; no rebase -x, bisect run, filter-branch, submodule foreach, difftool/mergetool commands; no force-push to main/master (or without an explicit branch); no deletion of main/master; no --exec-path. Everything else is allowed. |
kill, pkill, killall | No kill -1/kill 0; no system processes (launchd, systemd, sshd, dockerd, …). |
Shells (bash, sh, zsh, fish, dash, ksh, csh, tcsh, rc, elvish, nu, pwsh, powershell) | The inline command (-c, -Command) is validated recursively; reading the program from standard input and -EncodedCommand are refused. |
Wrappers (env, time, nice, nohup, xargs, timeout, setsid, stdbuf, caffeinate, flock, watch, strace, script, busybox, …) | Stripped, then the wrapped command is validated; a wrapper that writes a file (time -o, strace -o, script) obeys the write rules. |
alias, hash, enable, expect -c | Refused: they can give a blocked program another name or run code the policy cannot read. |
| Environment prefixes | PATH, CDPATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_*, IFS, BASH_ENV, ENV, PROMPT_COMMAND, PERL5OPT, PYTHONSTARTUP, NODE_OPTIONS, RUBYOPT and the GIT_* overrides cannot be set, in any form (VAR=x cmd, env VAR=x, export, declare, read, …). |
psql, mysql, mariadb, redis-cli, mongosh, mongo | No DROP, TRUNCATE, DELETE without WHERE, FLUSHALL/FLUSHDB, .drop(, dropDatabase, deleteMany({}). |
dropdb, dropuser | Only names containing test, dev, local, tmp, temp, scratch, sandbox or mock. |
curl, wget, nc, ssh, scp, sftp, ftp, telnet | Only when network access is allowed (see below). |
Anything touching .git/ | Writing tools, copies, links and redirections into .git/hooks, .git/config, .git/info, .git/worktrees or .git/modules are refused, and git worktree is limited to list. |
What you can configure
[security]
# Programs to block in addition to the built-in list.
blocked_commands = ["docker", "terraform"]
# If non-empty, ONLY these programs (and these wrappers, e.g. `nohup`) may run,
# plus harmless builtins: cd, echo, printf, true, false, pwd, test, [, exit, :.
allowed_commands = ["cargo", "git", "ls", "cat"]
# Default timeout of a shell command, in seconds (an agent may ask up to 600).
command_timeout_secs = 120
# Let agents use network clients (curl, wget, ssh, …).
allow_network = false
# Directories outside the project that agents may READ.
extra_read_paths = ["/usr/share/doc"]
When a command is denied, the agent receives the reason as a full sentence
(“Command denied by the security policy: rm may not target /”) and can choose another
approach.
Output limits
Tool output shown to the model is capped (30 000 characters for grep and bash,
100 000 for any tool). The full text of a truncated output is saved under
.vibe/tool-output/<uuid>.txt and the path is given to the agent.
Hooks: your own rules
The policy above is static. For dynamic rules (“no npm publish on Fridays”, “log every
command to Slack”, “ask me before touching migrations”) write a hook, either in Rust or
as a plugin in any language; see Plugins and
Extension points.
What the policy is not
It is a denylist and a set of validators, not a sandbox. An agent that can run
python -c or node -e can, in principle, do anything the interpreter can. For untrusted
code or strict environments, combine the policy with an isolated workspace, keep
allow_network = false, and use the allowlist. The built-in
container workspace runs every shell command in a
hardened, throw-away container with no network by default; a VM or remote sandbox can be
plugged in as a WorkspaceProvider plugin. The file tools still act on the host worktree,
inside the path containment described above.
Workspaces and merging
Agents never edit your checkout by default. Each task gets its own workspace: a git worktree on a dedicated branch. The agents are confined to that directory, you keep working in your checkout, and several tasks can run side by side. When the work is approved, the branch is merged into the base branch, or left for you to review. The design rationale is in ADR-003.
Choosing a workspace
[pipeline]
workspace = "git_worktree" # default
# workspace = "in_place" # no isolation
# workspace = "container" # git worktrees, shell commands in containers
# workspace = "<name>" # a WorkspaceProvider registered by an in-process plugin
base_branch = "main" # top level; default: the branch currently checked out
| Value | Isolation | Needs git | Merge |
|---|---|---|---|
git_worktree | one worktree and branch per task | yes | fast-forward, merge commit, or human review |
in_place | none: agents work in the project directory | no | nothing to merge; changes are already there |
container | git worktrees as above, and every shell command in a throw-away container | yes, and Docker or Podman | as git_worktree |
Git worktrees
For a task titled “Add OAuth login (GitHub)” with id 6f1c3e0a-…, the workspace is:
| What | Value |
|---|---|
| name | <slug>-<first 8 characters of the task id>, for example add-oauth-login-github-6f1c3e0a |
| directory | <project>/.vibe/worktrees/add-oauth-login-github-6f1c3e0a |
| branch | vibe/add-oauth-login-github-6f1c3e0a |
| forked from | base_branch, else the branch currently checked out, else main, else master |
The slug is the title in lowercase, non-alphanumeric runs replaced by -, at most 48
characters. The directory .vibe/worktrees/ contains a .gitignore with *, so worktrees
never appear in your own git status.
The base branch is recorded in the repository configuration under
branch.vibe/<name>.vibebase, so a task always merges back into the branch it was forked
from, even if you change base_branch later:
git config --get branch.vibe/add-oauth-login-github-6f1c3e0a.vibebase
Reopening. Running a task again reuses its worktree. Before that, git worktree prune
forgets worktrees whose directory you deleted. If the directory exists but git does not know
it (a leftover), it is removed and recreated. If the branch exists but its worktree is gone,
a new worktree is created on the existing branch, keeping its commits.
The project must be a git repository; otherwise opening fails with
<path> is not a git repository; use the in_place workspace or run `git init` .
One worktree per subtask attempt
With git_worktree (and pipeline.isolate_subtasks = true, the default), every attempt at a
subtask also gets its own worktree, forked from the current commit of the task branch:
| What | Value |
|---|---|
| directory | <task worktree>--s<N>-a<attempt>, for example .vibe/worktrees/add-oauth-login-github-6f1c3e0a--s2-a1 |
| branch | <task branch>--s<N>-a<attempt>, for example vibe/add-oauth-login-github-6f1c3e0a--s2-a1 |
Parallel coder sessions therefore never see each other’s unfinished edits, and a failed attempt is thrown away with its worktree instead of being reset in a shared directory.
When a session reports its subtask done, its work is committed on the attempt branch
(vibe: complete subtask 2 - Routes) and merged into the task branch right away: a
fast-forward when the task branch did not move, else a merge commit. Integrations happen one
at a time; sessions that finish together are integrated in plan order, and a subtask starts
only once the work of its depends_on subtasks is integrated. If the merge conflicts with
work integrated since the attempt started, the merge is aborted (the task branch is left
exactly as it was), the attempt counts as failed with the conflicting files in its notes,
and the next attempt starts from the updated task branch. With merge_strategy = "assisted", the model of the merge phase first tries to resolve the conflict markers, as for
the final merge; the attempt only fails when a file stays unresolved.
Attempt worktrees and branches are removed after each attempt and, to clean up after a
crash, when the build starts and ends. vibe task discard removes any that remain. Before
the first attempt the task worktree is committed as vibe: checkpoint before build, so
attempts fork from everything already in it.
Set pipeline.isolate_subtasks = false to have parallel subtasks share the task worktree as
in 0.1 (completed subtasks are then committed when no other session is running, and failed
attempts are reset). in_place and plugin workspaces always share the task workspace.
Container workspace
The shell policy filters commands but is not a sandbox. With workspace = "container", the
bash tool, and therefore the required validation commands, runs every command in a fresh
container instead of on your machine:
[pipeline]
workspace = "container"
[workspace.container]
image = "rust:1.88" # required
runtime = "docker" # or "podman", or a path to either
network = "none" # default; "bridge" or a named network
cpus = 2.0
memory = "4g" # also the swap limit
pids_limit = 1024 # default
tmp_size = "1g" # size of the /tmp tmpfs
# user = "1000:1000" # default: owner of the worktree on Unix
env = ["CARGO_TERM_COLOR"] # host variables passed through, by name only
mount_git_metadata = false # mount the git directory read-only (Unix only)
[[workspace.container.mounts]]
source = "/home/me/.cargo/registry" # absolute, or relative to the project root; no `~`
target = "/usr/local/cargo/registry"
read_only = true # default
Files still live in the task’s git worktree on the host: it is bind-mounted at /workspace
and the command’s working directory is translated to the matching path inside the
container. The file tools, the diff shown to reviewers, commits, merges and validated merges
work exactly as with git_worktree, and so do subtask worktrees.
Every container is started with --rm, --cap-drop=ALL,
--security-opt=no-new-privileges, a read-only root file system, a /tmp tmpfs, a process
limit and a label io.vibe-factory.managed=true. There is no way to pass raw flags to the
runtime; unknown keys in [workspace.container] are an error. One container runs per command
and is removed with rm --force when the command ends, times out or is cancelled, which also
kills its background processes. The start-up costs a few hundred milliseconds per command.
What the settings allow and refuse:
- Network.
noneunless the configured network isbridgeor a named network andsecurity.allow_network = true.hostandcontainer:<id>are refused. - Mounts. The source must exist; the filesystem root and sockets (such as the Docker
socket) are refused; a writable mount may not overlap the project’s
.gitor.vibe; targets must be absolute and outside/workspace,/tmp,/proc,/sysand/dev. - Git. The worktree’s
.gitpointer is mounted read-only, and the repository’s git directory is not mounted unlessmount_git_metadata = true(then read-only), so git commands that write do not work inside the container; the framework commits on the host. - Failures of the runtime. An exit status of 125 (image missing, daemon down) is reported
to the agent as a runner failure with
sandbox_error: truein the tool metadata.
The settings are validated before a run starts. vibe doctor also checks that <runtime> info answers. The image must already contain the tools the agents need (compilers, test
runners); nothing is installed for you.
In-place mode
pipeline.workspace = "in_place" makes the project directory itself the workspace. Use it
for directories that are not git repositories, throw-away experiments, or when you want to
watch files change live. There is no branch, merging does nothing (NoChanges), and
discarding a task leaves your files untouched. Agents still cannot leave the project
directory.
What changed
The workspace summary shown to reviewers (and to you) has two parts, both excluding
.vibe/:
Committed changes since main:
src/auth/github.rs | 84 +++++++++++++++++++++++
src/routes.rs | 12 +++-
2 files changed, 94 insertions(+), 2 deletions(-)
Uncommitted changes:
M src/routes.rs
?? tests/auth_github.rs
The first part is git diff --stat <base>...HEAD (changes since the fork point); the second
is git status --short in the worktree.
Merge behaviour
With configured pipeline.validation_commands and auto_merge = true, integration is
prepared in a detached temporary worktree. The commands run against the combined result,
including assisted conflict resolutions. Only a passing, unchanged candidate is published
by fast-forwarding the base branch to that exact commit. A failed check, unresolved conflict,
changed target or unsupported provider pauses in review; the target is not updated by the
integration. Resume builds and checks a fresh candidate. See
Required validation commands.
Without configured validation commands, merging integrates the task branch into the base branch in your project checkout using the original algorithm:
- Anything left uncommitted in the worktree is committed as
vibe: checkpoint, excluding.vibe/, with the neutral identityVibe Factory <vibe-factory@localhost>. - If tracked files of your checkout have uncommitted modifications, the merge is refused:
cannot merge vibe/… : the project at … has uncommitted changes; commit or stash them first. Untracked files do not block it. - If the task branch has no commit ahead of the base, the result is no changes.
- Your checkout is switched to the base branch if needed, then
git merge --ff-onlyis tried; if the base moved,git merge --no-ffcreates a commitMerge vibe/… (vibe). - On conflicts, the merge is aborted, your checkout is switched back to the branch it was on, and the task is handed to you for human review with the list of conflicting files. Your checkout is left clean.
After a successful merge your checkout stays on the base branch. The worktree and branch are
kept until you discard the task. With pipeline.auto_merge = true the merge runs as soon as
QA and required validations approve; otherwise the task waits in ready after its task checks.
Assisted conflict resolution
The worktree provider can instead ask a model to resolve conflicts (MergeStrategy::Assisted).
For each conflicting file it sends the content with its markers, asks for the fully merged
file, and writes it back only if the answer contains no conflict marker. If every file is
resolved, the merge is committed; if any file is not, the merge is aborted and reported for
human review as above. Enable it with pipeline.merge_strategy = "assisted" in
.vibe/config.toml; the model used is the one configured for the merge phase (or
default_model).
Repository configuration is guarded
Agents may run git in the worktree, and a worktree shares the repository’s local
configuration (.git/config) with your checkout. To make sure an agent cannot smuggle
executable settings into your repository, the worktree provider:
- runs every git command of the framework with hooks,
fsmonitor, pagers, editors and SSH helpers neutralised (-c core.hooksPath=<empty dir>and friends); - takes a snapshot of the local configuration when the workspace is opened (kept in memory
and in
.vibe/worktrees/.snapshots/<task>.cfg); - refuses to merge when any key changed since, except the framework’s own
branch.vibe/*sections. The error lists the added, removed or modified keys, withcore.hooksPathnamed first when it is involved.
If you changed the configuration yourself in the meantime, review the listed keys and
either restore them or accept them (GitWorktreeProvider::accept_config_changes from code;
a CLI command is on the roadmap), then run the merge again.
Inspecting, merging and discarding by hand
Everything is plain git, so you can take over at any point:
git worktree list # every task workspace
git log --oneline main..vibe/add-oauth-login-github-6f1c3e0a
git diff main...vibe/add-oauth-login-github-6f1c3e0a
cd .vibe/worktrees/add-oauth-login-github-6f1c3e0a # run tests, edit, commit
cargo test
git commit -am "fix: handle missing email scope"
cd - # back to your checkout
git switch main
git merge vibe/add-oauth-login-github-6f1c3e0a # resolve conflicts as usual
To throw a task’s work away:
vibe task discard <task> # removes worktree and branch
# or by hand:
git worktree remove --force .vibe/worktrees/add-oauth-login-github-6f1c3e0a
git branch -D vibe/add-oauth-login-github-6f1c3e0a
git worktree prune
Discarding is idempotent: a worktree or branch that is already gone is not an error. The framework refuses to discard a workspace whose root is the project itself.
Disk usage
Each worktree is a full checkout of your tracked files (the object database is shared, so
history is not duplicated), plus whatever the agents build inside it: target/,
node_modules/, virtual environments. On large projects, budget one working copy and one
build directory per open task. Truncated tool outputs are also saved under
.vibe/tool-output/ inside the workspace. Discard finished tasks to reclaim the space, and
check with:
du -sh .vibe/worktrees/*
Customising agents
Every step of the pipeline is carried out by an agent: a model running in a loop with a
system prompt, a set of tools, a model choice and a thinking level. Vibe Factory ships twelve
of them. You can change any of their settings, replace their prompts, or add your own, by
dropping TOML files in .vibe/agents/. No code is needed.
The built-in roles
| Role | What it does |
|---|---|
complexity_assessor | Reads the task and a little of the code, then classifies it as trivial, simple, standard or complex. This picks how much of the pipeline runs. |
spec_gatherer | Explores the codebase and turns your description into numbered, testable requirements with the relevant files and conventions. |
spec_researcher | Checks the external libraries and APIs the requirements rely on, so the spec does not assume functions that do not exist. |
spec_writer | Writes the specification: a readable spec.md and the matching JSON. |
spec_critic | Reviews the specification for gaps and contradictions and returns a corrected version. |
planner | Breaks the spec into ordered phases of small subtasks, each with the files to touch, its dependencies and how to verify it. |
coder | Implements one subtask in a fresh session, then runs its verification. |
coder_recovery | Takes over a subtask that keeps failing, diagnoses why, and completes it another way. |
qa_reviewer | Checks every acceptance criterion against the code, runs the checks, and issues a verdict with a list of issues. |
qa_fixer | Fixes the issues listed in a QA report. |
merge_resolver | Resolves the conflict markers of one file. |
commit_message | Writes a conventional commit message for a set of changes. |
Their default settings:
| Role | Tools | Thinking | Ends with JSON | Max steps |
|---|---|---|---|---|
complexity_assessor | read only | low | yes | 30 |
spec_gatherer | read only | medium | yes | 100 |
spec_researcher | read only | medium | no | 100 |
spec_writer | read + write_file | high | yes | 100 |
spec_critic | read only | high | yes | 60 |
planner | read only | high | yes | 100 |
coder | all | low | no | 300 |
coder_recovery | all | medium | no | 300 |
qa_reviewer | all | high | yes | 200 |
qa_fixer | all | medium | no | 200 |
merge_resolver | none | low | no | 3 |
commit_message | none | low | no | 2 |
All of them use the model configured for the current phase (see Providers and models).
Overriding a role
Create .vibe/agents/<anything>.toml. The file names the role it applies to and sets only
the keys you want to change; everything else keeps its current value.
# .vibe/agents/qa_reviewer.toml
role = "qa_reviewer"
model = "anthropic/claude-opus-5"
thinking = "max"
max_steps = 250
| Key | Type | Meaning |
|---|---|---|
role | string, required | a built-in role name, or a new name for a custom agent |
description | string | shown in listings |
system_prompt | string | the whole system prompt, replacing the current one |
system_prompt_file | path | read the prompt from a file, relative to the TOML file |
tools | "none", "read_only", "read_write", "all", or a list of tool names | tools the agent may use |
model | "phase" or "provider/model" | phase follows the phase configuration; a reference pins one model |
thinking | off, low, medium, high, max | reasoning budget |
max_steps | integer ≥ 1 | maximum number of model round trips |
max_tokens | integer ≥ 1 | output token budget per round trip |
structured_output | bool | the agent must end with a JSON document |
Rules:
- Unknown keys are an error, so a typo such as
max_stepis reported instead of ignored. system_promptandsystem_prompt_fileare mutually exclusive.- A tool list only keeps tools that exist; an unknown name is silently dropped.
read_onlymeansread_file,glob,grepandlist_dir;read_writeaddswrite_file;allincludes tools from plugins. modelmust contain a/unless it isphase.- Errors name the file:
.vibe/agents/qa.toml: TOML error: unknown field `max_step` ….
Precedence
Files are read in file-name order. Each file applies on top of the agent already known for its role: the built-in agent, an agent provided by a plugin, or an earlier file in the same directory. Two files for the same role therefore layer, the later one winning on the keys both set. Keep one file per role unless you have a reason not to.
Replacing a prompt
Copy the built-in prompt from crates/vibe-agents/src/prompts/<role>.md as a starting point
and point system_prompt_file at your copy:
# .vibe/agents/coder.toml
role = "coder"
system_prompt_file = "prompts/coder.md" # .vibe/agents/prompts/coder.md
Prompts are templates: {{name}} is replaced by a variable, and unknown variables become
empty text. Every agent gets {{task_title}}, {{task_description}}, {{workspace_root}}
and {{date}}. The built-in prompts start with an HTML comment listing the other variables
their role receives ({{spec}}, {{plan}}, {{subtask}}, {{progress}}, {{memory}},
{{qa_report}}, {{prior_context}}, …); that comment is stripped before the prompt reaches
the model, so you can keep it as documentation. The full table is in
Agent runtime.
If a role ends with JSON, your prompt must still ask for the same JSON shape: the pipeline parses it into typed data.
Adding a custom role
Any role that is not a built-in name defines a new agent. It must provide a prompt; the
other keys default to all tools, the phase model, medium thinking, 200 steps and 8 192
output tokens.
# .vibe/agents/doc_writer.toml
role = "doc_writer"
description = "Keeps the user guide in sync with the code"
system_prompt_file = "doc_writer.md"
tools = ["read_file", "glob", "grep", "write_file"]
thinking = "medium"
max_steps = 80
<!-- .vibe/agents/doc_writer.md -->
You maintain the documentation of the project at {{workspace_root}}.
Task: {{task_title}}
{{task_description}}
Update only files under docs/. Finish with a short list of the pages you changed.
The default pipeline drives the built-in roles; a custom role is available to plugins and programs that look agents up by name in the registry. Plugins can also contribute agents over the plugin protocol (see Plugins).
Writing prompts that return valid JSON
Agents with structured_output = true must end their session with one JSON document. The
runtime is tolerant, but these habits keep the retries (and their cost) away:
- Show the exact shape in the prompt, as a fenced
jsonblock with every key, and list allowed enum values literally ("approved | changes_requested | inconclusive"). Enum values aresnake_caseand must match exactly. - Ask for the JSON last, in a single
jsonfence, with nothing after it. The extractor takes the whole answer if it is pure JSON, else the lastjsonfence, else the last untagged fence, else the last balanced{…}or[…]in the text. An example block quoted earlier in the answer is harmless; a second document after the real one is not. - Objects or arrays only. A bare string or number is never accepted as the answer.
- No comments, no trailing commas. They are the usual reason for the repair step.
- Give enough output budget. Plans and specs are long; the built-ins use 32 000 tokens. If an answer is cut by the output limit, the agent is asked once to continue, which can split the JSON across two messages.
- Let it converge. Structured agents are told “Converge now” at 75 % of
max_steps; keepmax_stepslarge enough that exploration fits before that point.
When extraction fails, the runtime makes one cheap repair call without tools, then resumes the agent once with “Your previous answer was not valid JSON”. A second failure is reported as an error; for the QA reviewer it counts as an inconclusive round.
Plugins
A plugin is a separate program that gives agents new tools, adds agents, or vetoes tool calls. It can be written in any language: Vibe Factory starts it, talks to it over its standard input and output, and stops it at the end of the run. Existing Model Context Protocol (MCP) servers that run over stdio work as tool plugins without changes. The wire contract is described in Plugin protocol.
| A plugin can provide | Shown to agents as |
|---|---|
| tools | extra tools, offered to every agent whose tool selection is all or names them |
| agents | extra roles, or replacements for built-in ones |
a before_tool hook | a policy that sees every tool call and may block it |
Declaring a plugin in the configuration
Add a [[plugins]] entry to .vibe/config.toml for each plugin:
[[plugins]]
name = "jira"
command = ["jira-vibe-plugin", "--project", "PAY"]
env = { JIRA_URL = "https://jira.example.com", JIRA_TOKEN_FILE = "/run/secrets/jira" }
cwd = "tools/jira"
capabilities = ["tools"]
required = false
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | required | unique name, used in logs and listings |
command | list of strings | required | program followed by its arguments |
env | table of strings | none | extra environment variables; the rest of the environment is inherited |
cwd | path | the directory vibe runs in (normally the project root) | working directory of the process |
capabilities | list of tools, agents, hooks | none | informational only: the plugin’s handshake decides |
required | bool | false | when true, a plugin that fails to start stops the run |
A program written as a relative path with a separator (./bin/plugin, bin/plugin) is
resolved against cwd. A bare name (python3, jira-vibe-plugin) is looked up on PATH.
Installing a plugin from a manifest
A plugin can also ship its own manifest. Put it in a directory under .vibe/plugins/:
.vibe/plugins/
echo/
vibe-plugin.toml
bin/vibe-echo-plugin
# .vibe/plugins/echo/vibe-plugin.toml
name = "echo"
version = "0.1.0"
description = "Echoes its input"
command = ["./bin/vibe-echo-plugin"]
required = false
capabilities = ["tools", "agents", "hooks"]
[env]
ECHO_PREFIX = ">"
The keys are those of [[plugins]] plus version and description; there is no cwd,
because the plugin always runs in the manifest’s directory. capabilities may also be
written as a table ([capabilities] with tools = true, …). An invalid manifest is skipped
with a warning naming the file.
User-level plugins live in the same layout under your configuration directory and are available in every project:
| Platform | Directory |
|---|---|
| Linux | ~/.config/vibe/plugins/<name>/vibe-plugin.toml |
| macOS | ~/Library/Application Support/vibe/plugins/<name>/vibe-plugin.toml |
| Windows | %APPDATA%\vibe\plugins\<name>\vibe-plugin.toml |
When the same name is declared in several places, the first one wins, in this order:
[[plugins]] in .vibe/config.toml, then .vibe/plugins/, then the user directory. This
lets a project pin or replace a plugin you installed globally.
Using an MCP server as a plugin
Point a declaration at the server’s stdio command. For example, with a hypothetical
filesystem server mcp-fs-server that serves one directory:
[[plugins]]
name = "fs"
command = ["mcp-fs-server", "--stdio", "--root", "."]
Vibe Factory performs the MCP handshake, lists the server’s tools (following pagination) and registers them under their own names. Things to know:
- MCP servers provide tools only; they never add agents or hooks.
- A tool is treated as read-only only if the server marks it with the
readOnlyHintannotation. Other tools are treated as mutating and never run concurrently with other calls. - The framework passes the task’s workspace root and permissions in the request metadata,
but MCP servers ignore it. The server works on whatever you configured it for: here
--root .resolves againstcwd, that is your project checkout, not the task’s worktree. Give such servers the narrowest scope that works, and prefer read-only servers when agents run in isolated worktrees. - A tool whose name matches a built-in tool replaces it (a warning is logged). Rename the
server’s tools, or avoid servers that shadow
read_file,bashand friends.
The echo example
The repository ships vibe-echo-plugin, a complete plugin used by the tests. It offers:
- a tool
echothat returns itstextargument (with"workspace": trueit returns the workspace root it received instead); - an agent with the custom role
echo_agentthat may only useecho; - a
before_toolhook that blocks any call to a tool namedforbidden.
Build it and declare it:
cargo build --release -p vibe-plugins --bin vibe-echo-plugin
[[plugins]]
name = "echo"
command = ["./target/release/vibe-echo-plugin"]
You can also talk to it by hand to see the protocol:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocol_version":"1","host":{"name":"me","version":"0"}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}' \
'{"jsonrpc":"2.0","id":3,"method":"shutdown"}' \
| ./target/release/vibe-echo-plugin
Troubleshooting
Where are the logs? Everything a plugin writes to its standard error is logged by Vibe
Factory at debug level, prefixed with [<plugin name>]. Log notifications sent over the
protocol are logged at their own level. Enable debug logging to see both. A plugin must never
print diagnostics to standard output: that stream carries the protocol, and a stray line is
reported as ignoring invalid line from plugin.
The plugin does not start. The message names the program and the directory it was started in:
skipping plugin `jira`: Plugin: cannot start plugin `jira` (`jira-vibe-plugin` in `/repo`): No such file or directory
Check the program is on PATH or use a path relative to cwd, and that it is executable.
Required or optional? An optional plugin (the default) that fails to start is skipped
with a warning and the run continues without it. A plugin with required = true that fails
stops loading: every plugin already started is shut down and the run reports
required plugin `x` failed to start: …. Mark a plugin required when running without it
would be unsafe, typically a policy plugin whose hook blocks dangerous commands.
Timeouts. A tool call may take up to 600 seconds; every other request, including the
handshake, 60 seconds. A plugin that does not answer in time produces
plugin `x` did not answer `tools/call` within 600s, which the agent sees as a failed tool
call. At the end of the run each plugin gets 2 seconds to answer shutdown and 2 more to
exit before it is killed.
Every tool call is blocked. A plugin hook that crashed or stopped answering blocks the
calls it guards (hook plugin unavailable: …): hooks fail closed on purpose. Restart the run
after fixing the plugin, or remove it.
Tools are missing. The handshake is authoritative: a plugin that does not report the
tools capability is never asked for its tools, whatever capabilities says in your
configuration. Also check the agent’s tool selection: roles with read_only or read_write
tools never see plugin tools unless you list them explicitly (see
Customising agents).
The same plugin twice. A name declared twice is loaded once; the duplicate is ignored with a warning.
Troubleshooting
Each section starts from what you see, explains the cause, and gives the fix. Two commands answer most questions:
vibe task show <ref> # status, last run, QA verdict, workspace
vibe doctor # environment, configuration, keys, plugins
Where to look
| Source | What it tells you |
|---|---|
.vibe/tasks/NNN-slug/progress.md | a timestamped note for every step: assessment, spec, plan, each subtask attempt with its failure reason, each QA round, merge result, pause reasons |
.vibe/tasks/NNN-slug/run.json | the last run: status, current_phase, completed_phases, qa_round, last_error |
.vibe/tasks/NNN-slug/events.jsonl | every event of every run: tool calls with their input, tool results, retries, agent stops |
.vibe/tasks/NNN-slug/plan.md | each subtask’s status, attempts and notes |
.vibe/tasks/NNN-slug/qa_report_<n>.md | the issues of each QA round |
vibe history <ref> | what each run did: phases, commits, changed files, validations, QA verdict, errors (History) |
vibe trace <ref> [--full] | every tool call of a run with its arguments, exit code and complete output (Tool call trace) |
vibe events --since 1h | the recent activity of every task, in time order |
standard error with -v, -vv, -vvv | logs at info, debug, trace level; RUST_LOG overrides |
vibe trace 3 --tool bash --full # every shell command of the last run, with its output
jq -r 'select(.event.type == "tool_returned" and .event.is_error) | "\(.event.tool): \(.event.preview)"' \
.vibe/tasks/003-*/events.jsonl
RUST_LOG=vibe_agents=debug,vibe_plugins=debug vibe run 3 --resume 2> run.log
The file layout is described in Persistence layout.
The first model call fails with AuthFailed
AuthFailed: no API key configured for provider `anthropic` (set `api_key` or `api_key_env`)
AuthFailed: HTTP 401: invalid x-api-key sk-***
Cause. The provider’s key is missing, empty or wrong. Providers load without a key and
only fail on their first call, so this appears at the start of assess or spec.
Fix. Export the variable named by api_key_env in the shell that runs vibe
(ANTHROPIC_API_KEY or OPENAI_API_KEY for the built-ins), or switch to a provider you have
(vibe run 3 --provider ollama). vibe doctor lists every declared provider, whether the configuration uses it, and
whether their keys are set. Then resume: vibe run 3 --resume.
InvalidRequest: HTTP 429: You exceeded your current quota is a billing problem, not a rate
limit: it is not retried. Check your account.
Retrying lines and rate limits
↻ retrying model call (claude-sonnet-5): HTTP 429: rate limit reached for requests (attempt 2, in 30.0 s)
Cause. A transient failure: rate limit (429), overloaded or server error (5xx), network
error or timeout. Providers first retry silently, four attempts with exponential backoff
(1 s doubling up to 60 s, honouring Retry-After); if the call still fails, the agent
runtime retries it twice more, and those retries are the lines you see. Nothing to do unless
it keeps happening.
Fix, if it persists. Lower pipeline.max_parallel_subtasks (each parallel subtask is a
concurrent session), use a smaller model for high-volume phases ([phases.build]), or run
fewer tasks at once. If all retries fail, the phase fails; resume the run later.
Retrying also appears for pipeline-level retries: plan: <validation error> when the
planner produced an invalid plan, and subtask `…` before another attempt of a subtask.
ContextTooLong and continuation
Cause. A session grew past the model’s context window (many large files read, long
command outputs). The provider reports it as ContextTooLong and the session stops with
context_window.
What happens. Coder sessions (build) continue automatically: the transcript is summarised into handover notes and a fresh session resumes from them, up to five times. Other agents do not continue; their session ends with whatever they produced, which usually leads to the JSON problem below.
Fix. Use a model with a larger context for the phase, split the task, or make subtasks
smaller by asking for it in the description. Large tool outputs are already truncated and
saved under .vibe/tool-output/.
A model returns invalid JSON
agent `planner` did not produce valid structured output after a retry: no JSON object or array found in the output
agent `qa_reviewer` did not produce valid structured output after a retry: JSON does not match the expected shape: invalid type: string "none", expected a sequence
Cause. Structured agents must end with a JSON document. When extraction fails, the runtime makes one repair call, then resumes the agent once asking for valid JSON. A second failure is an error. What follows depends on the role:
| Role | Effect of the error |
|---|---|
complexity_assessor | the task falls back to standard |
spec_gatherer | the spec phase fails; the run is failed |
spec_writer, spec_critic | ignored; the spec keeps the gatherer’s requirements (or the writer’s, for the critic) |
planner | counts as a failed attempt; retried with the error as feedback, up to max_phase_retries more times |
coder, coder_recovery | a failed attempt of the subtask |
qa_reviewer | the round is inconclusive: the run pauses with the task in review |
Fix. Small local models are the usual cause: use a stronger model for the phase
([phases.plan], [phases.qa]), raise max_tokens of the agent if answers are cut, and see
Writing prompts that return valid JSON
if you replaced a prompt. Then vibe run <ref> --resume.
A subtask fails three times
[2/4] Wire the export route failed
✓ build 3 done, 1 failed, 0 skipped
Cause. Each attempt ended without a coder report saying done. The third attempt
(max_subtask_attempts) used the recovery agent. The reasons are in plan.md (the
subtask’s notes) and progress.md; memory/gotchas.md records the failure for later runs.
Subtasks that depend on it are skipped.
What happens next. If at least one subtask is done and none is pending, the run goes on
to QA, which will likely request changes for the missing part. If nothing is done, the run
fails with build failed: ….
Fix. Read why it failed. Often a verification command cannot pass in the worktree (missing service, missing tool, command denied). Fix the cause, then:
vibe run 3 --resume # a failed build retries failed and skipped subtasks with a fresh budget
To finish the subtask yourself, do it in the worktree, commit, set its status to "done"
in plan.json, and run vibe run 3 --from qa.
QA never approves
⏸ paused: QA did not approve after 3 round(s); human review needed
⏸ paused: issue `export ignores --limit with --json` was reported in 3 consecutive QA rounds; escalating to a human
⏸ paused: QA round 2 was inconclusive; human review needed
Cause. The run pauses with the task in review (exit code 2) when QA still requests
changes after max_qa_rounds reviews in this invocation, when the same issue title appears
in three consecutive reports, when a review is inconclusive, or when a trivial task, which
has no fix phase, gets changes requested.
Fix. Read the latest qa_report_<n>.md. Then either:
- let the agents try again:
vibe run 3 --resumeresumes at QA with a fresh round budget. An escalated issue that is still reported three rounds in a row pauses again, so change something first (a hint inprogress.md, a stronger model forfix); - fix it by hand: edit and commit in the worktree
(
.vibe/worktrees/<slug>-<id>/), thenvibe run 3 --from qafor a new review; - accept it as is: merge the branch yourself (see Workspaces).
If QA is wrong about a requirement, correct the requirement in spec.json before re-running
QA; the reviewer checks acceptance criteria literally.
Merge conflicts
✓ merge merge conflicts in 2 file(s)
Task 3 is ready · branch vibe/add-oauth-login-github-6f1c3e0a
Cause. With auto-merge, the base branch moved and the task branch conflicts with it. The
workspace provider returned NeedsHumanReview: the merge was aborted, your checkout was put
back on its branch and left clean, and the task is ready with the conflicting files listed
in progress.md. The worktree and branch are left in place.
Fix. Merge by hand and resolve as usual:
git switch main
git merge vibe/add-oauth-login-github-6f1c3e0a
The merge is refused because the project is dirty
Automatic merge failed: cannot merge vibe/… : the project at /repo has uncommitted changes; commit or stash them first
Cause. Auto-merge works in your project checkout, and tracked files there have
uncommitted modifications. Untracked files do not block it. The task ends ready.
Fix. Commit or stash your changes, then merge the branch by hand, or run
vibe run 3 --from merge --auto-merge.
The worktree already exists
Cause. A task keeps its worktree between runs; running it again reuses it. A worktree directory that git does not know about (for example restored from a backup) is removed and recreated; a branch whose worktree was deleted gets a new worktree with its commits.
Fix. Usually nothing. If git itself complains that the branch is checked out elsewhere or the worktree is locked, clean up and retry:
git worktree prune
git worktree list
git worktree remove --force .vibe/worktrees/<slug>-<id> # only if you want to start over
A project that is not a git repository fails before the run starts with
/path is not a git repository: run `git init`, or use `--workspace in_place` .
A plugin fails to start
skipping plugin `jira`: Plugin: cannot start plugin `jira` (`jira-vibe-plugin` in `/repo`): No such file or directory
required plugin `policy` failed to start: …
Cause. The program is not on PATH, not executable, or crashes during the handshake. An
optional plugin is skipped with a warning and the run continues without its tools; a plugin
with required = true stops the run. The handshake times out after 60 seconds, a tool call
after 600.
Fix. Run vibe plugins check: it starts each plugin, prints what it offers or the
error, and stops it. Plugin standard error is logged at debug level, so add -vv. Details in
Plugins.
A command is denied by the security policy
bash ⟵ error: Command denied by the security policy: `rm` may not target `/`
Cause. The shell policy blocked a segment of the command. The agent receives the reason
and usually finds another way, so a single denial is not a problem. Repeated denials of a
command the task really needs (a network client, docker, a database client) will make
subtasks or QA fail.
Fix. Allow what is needed in [security]: allow_network = true for curl and friends,
remove the program from blocked_commands, or add it to allowed_commands if you use an
allowlist. System administration programs (sudo, mount, …) are always blocked. The rules
are listed in Tools and security.
vibe says your home directory is not a git repository
error: /Users/me is not a git repository: run `git init`, or use `--workspace in_place`
Cause. Before 0.4.0, vibe looked for the nearest parent holding a .vibe directory
without limit, and a ~/.vibe left by another tool made it take your home directory for the
project. Since 0.4.0 the search stops at the git repository and never climbs up to the home
directory; running vibe from the home directory itself still uses it, like any other
directory.
Fix. Check which binary runs (vibe --version, which -a vibe) and reinstall if it is
older than 0.4.0. With a current version, the project is the directory you are in (or the
nearest parent with .vibe inside the same repository): run vibe from inside the
repository, or name it with -C <dir>. The other tool’s ~/.vibe does not need to be
removed.
.vibe/tool-output/ keeps growing
Cause. The trace store keeps the complete output of every tool call of every run, up to
pipeline.trace_max_chars characters per call (100 000 by default), and nothing removes it
automatically. Long test suites and verbose builds fill it fastest.
Fix.
du -sh .vibe/tool-output/*/ # which tasks use the space
vibe task discard 3 --yes # a task you no longer need, with its outputs
rm -rf .vibe/tool-output/003-*/<run> # the outputs of one old run only
To keep less, lower pipeline.trace_max_chars; to keep nothing but the previews in the log,
set pipeline.trace_outputs = false. Removed outputs only make vibe trace --full fall back
to the preview. The directory is ignored by git. Details in
Tool call trace.
A vibe serve started by an application is still running
error: cannot listen on 127.0.0.1:7777
caused by: Address already in use (os error 48)
Cause. An application (an editor, the macOS app, a script) started vibe serve as a
child process and died without stopping it, for example after a crash or a kill -9. The
orphan server keeps its port, its token file .vibe/server.token, and possibly runs.
Fix. Find it and stop it with SIGINT, which cancels its runs cleanly (they stay
resumable) and removes the token file:
pgrep -fl "vibe.*serve"
kill -INT <pid>
An application that starts vibe serve should pass --exit-on-stdin-eof and keep a pipe
on the child’s standard input: when the application ends, however it ends, the pipe closes
and the server stops as on Ctrl-C. The macOS app does this. For a server you start by
hand, --port 0 picks a free port.
vibe history shows ~, + or -
# title status runs commits files tokens active cost finished
4 Add a cache done 2 3 5~ 84.1k+ 3 min 10 s+ - 2 d ago
These marks are not errors:
~on files: the list of changed files is approximate. The task branch is gone and no merge was recorded, so the list comes from the commit events or from the files the agents wrote.vibe history <ref>names the source.+on tokens or active time: a lower bound. A run was logged before 0.5 (its totals are unknown) or has not finished;vibe history <ref>shows which run.+on the cost: some tokens were used outside a finished agent session (a session cut by a crash, or the repair of an invalid structured answer) and are not priced.-as the cost: there is no[pricing]table, or a model the task used has no price in it (a local model needs a price of zero), or the task ran before 0.5 and its models are not recorded.
The detail also lists problems met while reading, such as a merge commit that git no longer has (after a rebase or a garbage collection): the history then falls back to the next source. See History.
Windows specifics
- Shell commands run through
cmd /Cbut are validated with POSIX shell rules, socmdsyntax such as%VAR%or^escapes may be denied as unparseable. Ask for plain commands in the task description, or pin the project’s commands in.vibe/agents/coder.toml. - Deep worktree paths can exceed 260 characters:
git config --global core.longpaths trueand enable long paths in Windows. - Antivirus scanners can lock files in
target/and make renames fail; exclude the project’s.vibe/worktrees/directory.
The run was interrupted
A crash or a killed terminal leaves run.json with "status": "running". Ctrl-C leaves it
cancelled. Both are resumable:
vibe run 3 --resume
Subtasks that were in progress go back to pending; completed ones are kept.
Resetting a task
| Goal | Do |
|---|---|
| re-run QA after manual changes | vibe run 3 --from qa |
| re-plan from the same spec | vibe run 3 --from plan |
| redo the spec after editing the description | edit description in task.json, then vibe run 3 --from spec |
| re-assess the complexity | set "complexity": null in task.json, or pass --complexity, then vibe run 3 |
| forget the last run | delete run.json; artefacts stay |
| start completely over | vibe task discard 3 --yes, then vibe task add again |
A task keeps its complexity once assessed, so a plain vibe run 3 never calls the assessor
again. --from starts a new run that reuses the files already on disk; delete spec.json or
plan.json if you want them regenerated rather than reused by later phases.
Upgrading
From 0.4 to 0.5
Version 0.5 is about seeing everything that was done: the history of finished work, the
activity of every task in one feed, the complete trace of each tool call, and a native macOS
app on vibe serve. Existing .vibe/config.toml files keep working, and runs started with
0.4 can be resumed with 0.5.
Upgrade
cargo install --git https://github.com/vincentlauriat/vibe-factory --tag v0.5.1 vibe-cli --force
vibe --version # vibe 0.5.1
vibe doctor
Nothing on disk needs converting and the event schema stays 2: 0.5 only adds optional
fields and two event types (committed, merged), which older clients must ignore anyway.
Logs written by 0.4 still load, with what they lack shown as such: vibe trace pairs their
tool calls with their results by order (paired: by order) and has only the 200-character
previews of their outputs, and vibe history marks their token and time totals as lower
bounds (+) unless run.json still describes the run, and shows no cost for them (their
model is not recorded). See Logs recorded before 0.5.
task.json gains a branch field, recorded the next time a run opens the task’s workspace.
Until then vibe pr, vibe task show and vibe task discard compute the branch from the
title as before.
Behaviour that changes by default
Complete tool outputs are kept. Every tool call’s output is written to
.vibe/tool-output/<task dir>/<run>/<call>.txt, at most 100 000 characters per call, so the
directory grows with every run. It is ignored by git (vibe init has always put
tool-output/ in .vibe/.gitignore; check that yours has it), removed per task by
vibe task discard, and can be limited or turned off:
[pipeline]
trace_max_chars = 20_000 # keep less per call
trace_outputs = false # or keep nothing but the previews in the log
Outputs may contain secrets printed by tools; they never leave your machine. See Tool call trace for disk usage and privacy.
vibe events <REF> --follow follows a resumed run to its end. It used to stop at the
run_finished left by an earlier pause of the same run. It also stops at the end of the run
when --after (or the new --type and --since) hides the run_finished event, where an
--after beyond it used to follow forever.
The web UI reads one event stream. The page opens a single GET /api/stream for the
whole project (after loading the recent events with GET /api/events) instead of one
stream per selected task. The per-task routes (/api/tasks/{ref}/events and
/api/tasks/{ref}/stream) are unchanged, so the VS Code extension and other clients keep
working. A task reference that matches several tasks is now answered with 400 instead of
500 on every route.
vibe tui has three screens (T Tasks, A Activity, H History) and a Trace tab.
Esc still quits from the Tasks board; on the other screens it closes what is open and
goes back. In the Trace tab, ↑ ↓ select calls instead of tasks.
New settings
| Key | What it does | Page |
|---|---|---|
pipeline.trace_outputs | keep the complete output of every tool call (default true) | Tool call trace |
pipeline.trace_max_chars | characters kept per call (default 100000) | same |
[pricing."<provider>/<model>"] | prices per million tokens, to show a cost in the history (none by default) | [pricing] |
New commands and options: vibe history [REF] [--all], vibe trace <REF>, vibe events
without a task (with --since, --type, --task, --follow), --since and --type for
vibe events <REF>, and vibe serve --exit-on-stdin-eof. New server routes:
GET /api/events, GET /api/stream, GET /api/history[/{ref}],
GET /api/tasks/{ref}/trace and GET /api/tasks/{ref}/trace/{call}/output; see
vibe serve.
The macOS app
apps/macos/VibeFactory is a SwiftUI client of vibe serve for macOS 14 and later. It
starts vibe serve --exit-on-stdin-eof for the project it opens, so it needs the 0.5
vibe on the machine. A signed, notarized DMG is attached to each release since v0.5.0; the app
then updates itself (Check for Updates…). Its README explains how to build it from source.
For library users
The Rust API is pre-1.0 and 0.5 changes it in a few places. Code that only uses the vibe
command line is not affected.
vibe_core::Eventhas two new variants,CommittedandMerged, and new fields:AgentStarted::model,ToolCalled::{call, subtask},ToolReturned::{call, subtask, exit_code, timed_out, output_chars, output_file}andRunFinished::{usage, active_ms, started_at}. Amatchneeds the new cases (or a wildcard) and..in struct patterns; code that builds these events must fill the fields.vibe_core::CallIdidentifies a tool call.Taskhas a newbranchfield,PipelineConfignewtrace_outputsandtrace_max_charsfields, andVibeConfigapricingtable ofModelPrice: use..Default::default()rather than full struct literals.vibe_agents::ToolTraceandAgentRunner::tool_tracewrite the complete output of each call;RunContexthas a newtool_tracefield andRunContext::committakes the subtask the commit belongs to (Nonefor checkpoints and fixes).PipelineStorehas three new methods with defaults,task_dir_name,task_numbersandis_running; implement them in a custom store to name trace directories after the task and to report running tasks.vibe_pipelinehas a read layer (ADR-008): the modulesevents_log(EventReader,TaggedEnvelope,EventCursor,AllEventsFollower),history(TaskHistory,task_history,project_history) andtrace(RunTrace,Call,run_trace,read_output).vibe-pipelinenow depends onvibe-workspace(read-only git queries of the history).
From 0.2 to 0.4
Version 0.4 includes the work planned for 0.3 (streaming, numbered events, human approvals,
the run manager and the terminal UI) and for 0.4 (vibe serve, the web UI, integrations).
There was no 0.3 release. Existing .vibe/config.toml files keep working, and runs started
with 0.2 can be resumed with 0.4.
Upgrade
cargo install --git https://github.com/vincentlauriat/vibe-factory --tag v0.4.0 vibe-cli --force
vibe --version # vibe 0.4.0
vibe doctor
Nothing on disk needs converting. Events written by 0.2 in events.jsonl have no schema
or seq field: they read as schema 1 and are not replayed by vibe events; a resumed run
numbers its new events from 1. run.json gains approval fields that load empty.
Behaviour that changes by default
Answers are streamed. Anthropic and OpenAI-compatible providers now use server-sent
events, and vibe --json prints agent_delta lines while a step is generated (they are not
kept in events.jsonl). For a gateway that mishandles streaming, set
stream = false in its [providers.<name>] table. See Streaming.
Project memory is on. Lessons of a finished task are kept in .vibe/memory.jsonl and
the relevant ones are recalled by later tasks. Turn it off with
pipeline.project_memory = false; vibe memory list and vibe memory clear inspect and
reset it.
One process per task. A second vibe run on a task that is already running fails
instead of racing the first one; vibe cancel <task> stops a run from another terminal.
Anthropic thinking. Current Anthropic models (Claude 4.6 and later, including the
default claude-sonnet-5) get adaptive thinking with an effort level and no temperature,
which they require. Older models keep the token budget form; extra.thinking forces either.
New settings, off by default
| Key | What it does | Page |
|---|---|---|
pipeline.approvals | pause for vibe approve / vibe reject after spec, plan or before merge | Human approvals |
security.web_allowed_domains, security.search_url | limit web_fetch to some domains; the SearXNG-compatible endpoint that registers web_search | Configuration |
[integrations.github], [integrations.gitlab] | vibe task import and vibe pr | CLI |
New commands: vibe events, vibe approve, vibe reject, vibe cancel, vibe tui,
vibe serve, vibe memory, vibe task import and vibe pr. A run paused for an approval
exits with code 2, like any run that waits for a human.
For library users
The Rust API is pre-1.0 and 0.4 changes it in a few places. Code that only uses the vibe
command line is not affected.
vibe_core::Eventhas new variants (AgentDelta,ValidationFinished,SubtaskIntegrated,BudgetUpdated,ArtefactWritten,ApprovalRequested,ApprovalResolved); amatchon it needs a wildcard arm or the new cases.vibe_core::Envelopehas newschemaandseqfields: build one withEnvelope::now.ModelProvider::complete_streaminghas a default that callscomplete; implement it to stream.StreamDeltaandDeltaSinkare new.PipelineConfighas newapprovalsandproject_memoryfields, the provider configuration astreamfield,SecurityConfigweb_allowed_domainsandsearch_url, andConfiganintegrationstable: use..Default::default()rather than full struct literals.vibe_pipeline::RunManagerstarts, resumes, cancels and lists runs and is the seam every interface uses (ADR-007).RunStatehas new approval fields.
From 0.1 to 0.2
Version 0.2 is about reliability. Existing .vibe/config.toml files keep working, and runs
started with 0.1 can be resumed with 0.2. This page lists what changes when you upgrade and
what you may want to turn on.
Upgrade
cargo install --git https://github.com/vincentlauriat/vibe-factory --tag v0.2.0 vibe-cli --force
vibe --version # vibe 0.2.0
vibe doctor
Nothing on disk needs converting. run.json gains new fields (validations,
validation_fix_attempts, pending_validation_fix, usage, active_ms); files written by
0.1 load with empty or zero values, so a resumed 0.1 run starts its budgets from zero.
Behaviour that changes by default
One worktree per subtask attempt. With the default git_worktree workspace, every
subtask attempt now runs in its own worktree and branch (<task branch>--s<N>-a<attempt>)
and is merged into the task branch as soon as it is done. You will see:
- a
vibe: checkpoint before buildcommit when the task worktree had uncommitted work; - one
vibe: complete subtask N - <title>commit per subtask, and merge commits when parallel subtasks finished out of order, instead of grouped commits (vibe: complete subtasks 2, 3 - …); - temporary
…--s2-a1worktrees under.vibe/worktrees/and branches while a build runs, removed after each attempt.
A subtask whose changes conflict with work integrated while it ran is retried from the updated task branch, which can cost one more attempt than in 0.1. To keep the 0.1 behaviour (parallel subtasks share the task worktree), set:
[pipeline]
isolate_subtasks = false
in_place and plugin workspaces are unchanged. See
One worktree per subtask attempt.
Exit code 2 has one more meaning. vibe run also exits with 2 when a run budget is
exhausted (see below). Scripts that treat 2 as “QA needs a human” should read
run_status and last_error in the --json summary.
Plugin timeouts no longer close the plugin. A request that times out while its line is being written used to close the plugin’s input. Writes now go through a single writer task that never leaves a partial line, so the plugin stays usable after a timeout. A request cancelled before its turn is not sent at all.
New settings, off by default
All of these are optional; leaving them out keeps the 0.1 behaviour.
| Key | What it does | Page |
|---|---|---|
pipeline.validation_commands | shell checks that must pass before ready or a merge, run by the pipeline itself | Required validation commands |
pipeline.max_validation_fix_attempts | automatic fixes of failed checks over the whole run (default 2) | same |
pipeline.max_tokens | pause a run after this many tokens, resumes included | Run budgets |
pipeline.max_duration_secs | pause a run after this much active time, resumes included | same |
pipeline.workspace = "container" | run the agents’ shell commands in a container | Container workspace |
vibe run gains --max-tokens and --max-duration to set the budgets for one invocation.
A reasonable starting point for a Rust project:
[pipeline]
validation_commands = ["cargo fmt --all -- --check", "cargo test --offline"]
max_tokens = 3_000_000
max_duration_secs = 7200
For library users
The Rust API is pre-1.0 and 0.2 changes it in a few places. Code that only uses the vibe
command line is not affected.
vibe_core::PipelineConfighas new public fields (validation_commands,max_validation_fix_attempts,isolate_subtasks,max_tokens,max_duration_secs). Build it withPipelineConfig { …, ..PipelineConfig::default() }rather than a full struct literal.vibe_pipeline::PipelineDepshas a newsubtask_workspacesfield. PassNoneto share the task workspace between subtasks, orSome(Arc::new(GitSubtaskWorkspaces::new()))with a git worktree workspace.vibe_pipeline::RunContexthas newbudgetandsubtask_workspacesfields, andRunStatenewvalidations,validation_fix_attempts,pending_validation_fix,usageandactive_msfields.WorkspaceProviderhas a newmerge_validatedmethod used when validation commands are configured withauto_merge. Its default fails closed: a custom provider must implement it to support validated automatic merges.- New traits and types:
vibe_core::SubtaskWorkspacesandSubtaskIntegration(implemented byvibe_workspace::GitSubtaskWorkspaces),MergeValidator, andRunBudget,BudgetLimits,BudgetExceeded.AgentRunner::budgetattaches a budget to an agent run; a reached limit stops it like a cancellation.
Architecture overview
Vibe Factory is a Cargo workspace of eight crates arranged in strict layers. Every arrow points downwards; no crate depends on a crate above it.
┌─────────────┐
│ vibe-cli │ command line, terminal UI, server: wires everything together
└──────┬──────┘
┌──────────┬───────────┼───────────┬────────────┐
│ │ │ │ │
│ ┌──────▼──────┐ │ │ │
│ │vibe-pipeline│ │ │ │ orchestration, task store, run state, read layer
│ └──────┬──────┘ │ │ │
│ ┌──────▼──────┐ │ │ │
│ │ vibe-agents │ │ │ │ agentic loop, prompts, structured output
│ └──────┬──────┘ │ │ │
┌──────▼─────┐ │ ┌──────▼─────┐ ┌───▼─────────┐ ┌▼─────────────┐
│vibe-provid.│ │ │ vibe-tools │ │vibe-workspac│ │ vibe-plugins │
└──────┬─────┘ │ └──────┬─────┘ └───┬─────────┘ └┬─────────────┘
└──────────┴───────────┴───────────┴────────────┘
┌──────▼──────┐
│ vibe-core │ domain model, traits, registry, events
└─────────────┘
Only vibe-pipeline depends on vibe-agents (and on vibe-workspace, whose git helpers
its read layer uses to rebuild a task’s history); every other crate depends on vibe-core
alone and reaches the others through the traits in the Registry. The CLI is the only
place where concrete implementations meet.
The three ideas
1. The core is data and traits, nothing else. vibe-core has no I/O, no HTTP, no git.
It defines what a task, a spec, a plan, a QA report and a message are, and what a
provider, a tool, an agent, a workspace, a memory store and a hook do. Everything above it
is an implementation of those traits; everything can be replaced.
2. Agents are declarative. An agent is an AgentSpec: a role, a system prompt template,
a tool selection, a model selection and a thinking level. It can be loaded from TOML, built
in code or handed over by a plugin. The runtime (vibe-agents) executes any spec the same
way: the agentic loop calls the model, executes the tools it asks for, feeds results back,
and stops on a well-defined set of conditions.
3. The pipeline is a state machine over persisted artefacts. Each phase reads artefacts
written by the previous one (task.json, spec.md, plan.json, qa_report.json, progress
notes) from the task directory and writes its own. Because state lives on disk, a run can be
inspected, resumed after a crash, or continued by a human.
Request flow
- The CLI loads
.vibe/config.toml, builds the Registry (built-in providers, tools, agents and workspaces, then plugins), and opens the task store. Pipeline::run(task)publishesRunStarted, then walks the phases chosen by the complexity profile.- For each phase the pipeline picks the model for that phase, resolves the agent spec,
builds an
AgentRunner, and runs it with a kickoff message containing the relevant prior artefacts. - Structured outputs (JSON) are parsed into domain types and saved; free-form outputs are saved as markdown.
- QA loops with the fixer until approval or the round limit; merge integrates the worktree or leaves the branch for a human.
- Every step publishes events on the
EventBus; the CLI renders them live and the task store appends them toevents.jsonl.
Reading order
- Crates — what each crate owns.
- Domain model — the types.
- The pipeline — phases, profiles, retries, parallelism.
- Agent runtime — the loop and its stop conditions.
- Extension points — the traits and how to implement them.
- Plugin protocol — the wire contract.
- Security model — what agents may and may not do.
- Persistence layout — what is on disk and why.
- ADRs — the decisions and their rationale.
Crates
Vibe Factory is a cargo workspace of eight crates. This page describes what each one owns, the public types you will meet first, what it is allowed to depend on, and how it is tested. The Architecture overview explains why the layering exists; this page is the map.
Dependency graph
The graph below is the one declared in the Cargo.toml files. The implementation crates
depend on vibe-core and on nothing else from the workspace: vibe-agents reaches models,
tools and hooks only through the core traits, so it does not link any concrete provider,
tool set, workspace or plugin transport. vibe-pipeline builds on vibe-agents, and on
vibe-workspace for the git reads of its read layer (the history of a task: its branch, its
merge commit, the files it changed); the pipeline itself still reaches workspaces through
the WorkspaceProvider trait. Wiring concrete implementations together is the job of
vibe-cli.
vibe-cli (binary `vibe`: commands, terminal UI, HTTP server and web UI)
│ depends on every crate below
├──────────────────────────────┬────────────────┬──────────────┐
│ │ │ │
vibe-pipeline │ │ │
│ │ │ │
├──────────────┐ │ │ │
│ │ │ │ │
vibe-agents vibe-workspace vibe-providers vibe-tools vibe-plugins
│ │ │ │ │
└──────────────┴───────┬───────┴────────────────┴──────────────┘
│
vibe-core (types + traits, no I/O)
| Crate | Workspace dependencies | Notable external dependencies |
|---|---|---|
vibe-core | none | serde, serde_json, toml, tokio (sync only), chrono, uuid, indexmap, thiserror, async-trait |
vibe-providers | vibe-core | reqwest (rustls), regex, tokio; wiremock in tests |
vibe-tools | vibe-core | globset, ignore, regex, similar |
vibe-workspace | vibe-core | tokio (process); shells out to git |
vibe-plugins | vibe-core | tokio (process, io), futures, dirs, indexmap |
vibe-agents | vibe-core | futures, tokio, chrono, uuid, toml |
vibe-pipeline | vibe-core, vibe-agents, vibe-workspace | tokio, fs4 (file locks), futures, chrono, uuid, regex |
vibe-cli | every crate above | clap, ratatui, axum, reqwest, tokio, console, toml_edit |
Workspace-wide rules apply to every crate: unsafe_code = "forbid", missing_docs = "warn"
(promoted to an error in CI), clippy::all = "warn", edition 2024. See
ADR-001.
vibe-core
Responsibility. The vocabulary of the framework: domain types, the extension traits, the
Registry that collects implementations, the EventBus, the configuration model and the
prompt template engine. The crate documentation states the rule bluntly: no I/O and no
network code. The only filesystem access is VibeConfig::load/save and the lenient
canonicalisation used by ToolContext::resolve_path.
Key public types.
| Module | Types |
|---|---|
task, spec, plan, qa | Task, TaskStatus, Complexity, TaskSource, Spec, Requirement, SpecContext, Plan, PlanPhase, Subtask, SubtaskStatus, QaReport, QaIssue, QaVerdict, Severity |
message, provider | Message, ContentBlock, Role, CompletionRequest, CompletionResponse, StopReason, Usage, ModelRef, ToolSpec, ModelProvider |
tool | Tool, ToolContext, ToolOutput, Permissions, ToolRegistry |
agent | AgentSpec, AgentRole, ToolSelection, ModelSelection, ThinkingLevel, AgentStop, AgentOutcome |
workspace, memory, store | WorkspaceProvider, Workspace, MergeOutcome, InPlaceWorkspace, MemoryStore, InMemoryStore, TaskStore |
plugin, event | Plugin, Hook, HookDecision, Registry, Event, Envelope, EventBus, EventSink |
config, prompt, error | VibeConfig, ProviderConfig, PipelineConfig, SecurityConfig, PluginConfig, PromptTemplate, Error, ErrorKind |
Must never depend on any other workspace crate, an HTTP client, git, or a process API.
Adding such a dependency would force every plugin author to compile it.
Tests. Unit tests next to each module: serde round trips (Task, ids, ContentBlock
tags), Plan::validate on cycles and unknown dependencies, ToolSelection::resolve, path
containment in ToolContext::resolve_path (including .. and absolute paths), the
EventBus reaching both subscribers and sinks, VibeConfig TOML round trip and per-phase
overrides.
vibe-providers
Responsibility. Every ModelProvider shipped with the framework and the plumbing they
share: one pooled HTTP client, error classification, secret scrubbing, retry with
exponential backoff, and a registry that builds named providers from [providers.<name>].
Key public types. AnthropicProvider (Messages API, extended thinking, prompt caching,
AnthropicAuth::ApiKey or AnthropicAuth::Bearer), OpenAiCompatibleProvider (Chat
Completions for OpenAI, Groq, Mistral, xAI, OpenRouter, Ollama and gateways), MockProvider
and MockStep, ProviderRegistry and ProviderRegistryBuilder (with register_kind for
new kinds), RetryPolicy and with_retry, classify_http_error, scrub_secrets,
expand_model_shorthand.
Must never depend on vibe-agents or anything above it. A provider sees a
CompletionRequest and nothing about agents, tasks or phases.
Tests. Unit tests for classification rules (billing wording wins over a 429, context
length on 400/413), backoff arithmetic and jitter bounds, header parsing and scrubbing.
Integration tests in tests/anthropic_http.rs and tests/openai_http.rs run each provider
against a wiremock server: tool use round trip, 429 then success, retries exhausted with
retry_after preserved, 401 and billing errors not retried, malformed JSON, bearer mode and
custom headers, and replay of signed thinking blocks on the next tool turn.
vibe-tools
Responsibility (from ARCHITECTURE_EN.md; the crate is still being written). The built-in
tools read_file, write_file, edit_file, list_dir, glob, grep and bash, the shell
command parser and security policy, and output truncation. The policy is described in
ADR-004.
Must never depend on vibe-agents or above. Tools implement vibe_core::Tool and are
confined through ToolContext.
vibe-workspace
Responsibility. Where a task’s agents work and how their work reaches the main line.
Implements WorkspaceProvider with git worktrees, re-exports InPlaceWorkspace, and offers
commit helpers and an optional model-assisted conflict resolver.
Key public types. GitWorktreeProvider (fields base_branch, worktrees_dir,
merge_strategy), MergeStrategy (Manual or Assisted { provider, model }),
resolve_conflicts and Resolution, commit_all, has_uncommitted, the Git wrapper,
and provider_by_name("git_worktree" | "in_place", base_branch).
Must never depend on vibe-providers concretely: the assisted merge takes any
SharedProvider. It must not depend on vibe-agents either; its merge prompt is a
constant (MERGE_SYSTEM_PROMPT) rather than an agent spec.
Tests. tests/worktree.rs creates real repositories in temporary directories and
exercises: creation and reuse of a worktree, recreation of a stale directory, refusal on a
non-repository, changes output, fast-forward with checkpoint commit, merge commit when the
base moved, conflicts reported for human review with the project left clean and on its
original branch, NoChanges, refusal on a dirty project, idempotent discard, and an
assisted merge driven by a MockProvider-style provider.
vibe-plugins
Responsibility. The Vibe Plugin Protocol (VPP): wire types, a client that drives a plugin
process, adapters turning a connection into Tool, Hook and AgentSpec contributions,
manifest parsing and discovery, the PluginHost failure policy, and a PluginServer helper
for writing plugins in Rust. Ships the vibe-echo-plugin example binary. See
Plugin protocol.
Key public types. PluginHost, LoadedPlugin, RemotePlugin, RemoteTool,
RemoteHook, PluginProcess, PluginManifest, PluginServer, PluginLogger,
ToolCallContext, Capabilities, constants PROTOCOL_VERSION, MCP_PROTOCOL_VERSION,
DEFAULT_TIMEOUT, TOOL_CALL_TIMEOUT, SHUTDOWN_GRACE.
Must never depend on vibe-agents, vibe-tools or vibe-providers. A plugin is a source
of extensions, not a consumer of the built-in ones.
Tests. Unit tests in client.rs use tokio::io::duplex to script a fake peer
(timeouts, errors, pagination, notifications). tests/echo_process.rs spawns the real
vibe-echo-plugin binary: tools, agents and hooks are contributed, manifest discovery finds
it, a broken optional plugin does not block the others, and a relative program resolves
against cwd.
vibe-agents
Responsibility. The runtime that turns an AgentSpec into a conversation: the
AgentRunner loop, structured-output extraction and repair, continuation after context
exhaustion, the twelve built-in agents and their prompts, and TOML overrides. See
Agent runtime.
Key public types. AgentRunner, run_structured, run_structured_with_hint,
extract_json, repair_json, run_with_continuation, ContinuationPolicy,
builtin_agents, register_builtin_agents, load_agent_overrides,
apply_agent_overrides, project_agents_dir, documented_variables, and the
test_support module (ScriptedProvider, EchoTool, FailingTool, ConcurrencyProbe).
Must never depend on a concrete provider, tool, workspace or plugin crate. The lib
documentation calls this out: models come through ModelProvider, capabilities through
Tool, policies through the hooks of a Registry.
Tests. tests/agent_loop.rs drives the loop with ScriptedProvider and mock tools, no
network: parallel read-only calls, sequential mutating calls, MaxSteps, hook vetoes seen by
the model, the 85 % warning and 90 % stop, every structured-output path (fenced, unfenced,
repair, retry, give up), continuation and its limit, unknown, failing and panicking tools,
truncation to .vibe/tool-output/, cancellation, provider error handling and retries, the
max-tokens nudge, convergence at 75 %, prompt variables and hook augmentation, published
events, and a smoke run of every built-in agent.
vibe-pipeline
Responsibility. The file-backed TaskStore under .vibe/tasks/NNN-slug/
(FileTaskStore), complexity profiles, phase orchestration, parallel subtask execution
honouring depends_on, the QA/fix loop with escalation, human approvals, budgets, run state
and resume, and the event log. RunManager is the seam every interface uses to start,
resume, cancel and list runs (ADR-007). See
The pipeline.
Read layer (ADR-008): events_log
(EventReader, TaggedEnvelope, EventCursor, AllEventsFollower) reads one log
incrementally or every task’s log in cursor order; history rebuilds what a task did
(TaskHistory, project_history, task_history); trace pairs the tool calls of a run
(RunTrace, Call, run_trace, read_output). They are described in
Persistence layout.
Tests. tests/pipeline.rs runs whole pipelines on the scripted provider and a temporary
git repository; tests/history.rs builds histories from logs of both schemas and from real
git histories (branch, fast-forward, merge, reset).
vibe-cli
Responsibility. The vibe binary: every command of the
Command line reference, the live rendering of events, the terminal UI
(vibe tui, module tui) and the HTTP server with its embedded web UI (vibe serve,
module server). Every interface goes through RunManager and the read layer; none keeps
a state of its own.
Where to add code
| You want to… | Put it in |
|---|---|
| add a field to a persisted artefact | vibe-core (with #[serde(default)] so old files still load) |
| support a new model API | vibe-providers, or out of tree via ProviderRegistryBuilder::register_kind |
| change how the loop reacts to a stop reason | vibe-agents::runtime |
| change a built-in prompt | crates/vibe-agents/src/prompts/<role>.md |
| add a protocol method | vibe-plugins::protocol, then client, server and remote adapters |
| isolate work somewhere other than a worktree | a new WorkspaceProvider, in any crate |
Domain model
Every artefact the framework produces or exchanges is a plain Rust type in vibe-core,
serialisable with serde. This page describes those types and shows how they look on disk.
All types derive Serialize, Deserialize, Clone, Debug and PartialEq; most enums use
#[serde(rename_all = "snake_case")], so TaskStatus::InProgress is written "in_progress".
Task ──1:1── Spec ──1:1── Plan ──1:n── PlanPhase ──1:n── Subtask
│ │
└──1:n── QaReport (one per round) ──1:n── QaIssue └── depends_on: [SubtaskId]
Run: RunId ──publishes──▶ Event (wrapped in Envelope) ──▶ EventBus
Agent session: SessionId, Message[] of ContentBlock, Usage
Identifiers
TaskId, SubtaskId, SessionId and RunId are generated by one macro in ids.rs: a
newtype over a v4 uuid::Uuid, #[serde(transparent)], so each serialises as a bare string.
| Method | Purpose |
|---|---|
new() / default() | fresh random id |
parse(&str) / FromStr | parse the canonical UUID form |
short() | first 8 characters, used in branch and directory names |
Display | full UUID |
The ids are distinct types, so passing a TaskId where a SubtaskId is expected does not
compile.
Task
A Task is the unit of work handed to the pipeline.
| Field | Type | Serde |
|---|---|---|
id | TaskId | required |
title | String | required |
description | String | required |
status | TaskStatus | required |
complexity | Option<Complexity> | required key, null until assessed |
labels | Vec<String> | default |
source | TaskSource | default (manual) |
created_at, updated_at | DateTime<Utc> | RFC 3339 strings |
branch | Option<String> | default, omitted when None; the workspace branch recorded by the last run |
Task::new creates a backlog task; set_status and touch bump updated_at; slug()
applies slugify (lowercase ASCII alphanumerics separated by -, at most 48 characters,
"task" when empty).
TaskStatus | Meaning | is_terminal() |
|---|---|---|
backlog (default) | created, not started | no |
planning | spec and plan being produced | no |
building | subtasks being implemented | no |
review | QA reviewing or fixing | no |
ready | waiting for a human to review and merge | no |
done | merged or accepted | yes |
failed | the pipeline gave up | yes |
cancelled | cancelled by the user | yes |
Complexity is ordered (trivial < simple < standard < complex) and exposes two thresholds:
needs_spec() is true from simple upwards, needs_research() from standard upwards.
TaskSource is internally tagged with kind: {"kind": "manual"},
{"kind": "issue", "provider": "github", "reference": "412"} or
{"kind": "derived", "parent": "<task id>"}.
A task as serialised by serde_json:
{
"id": "6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19",
"title": "Add OAuth login with GitHub",
"description": "Users should be able to sign in with their GitHub account.",
"status": "planning",
"complexity": "standard",
"labels": ["auth"],
"source": { "kind": "issue", "provider": "github", "reference": "412" },
"created_at": "2026-09-27T08:12:44.120Z",
"updated_at": "2026-09-27T08:15:02.871Z"
}
Spec
| Type | Fields |
|---|---|
Spec | task_id, summary, requirements (default []), context (default), body (default "", free markdown) |
Requirement | id ("R1"…), description, kind (default functional), priority (default 1, 1 = must have), acceptance (default []) |
RequirementKind | functional, non_functional, constraint |
SpecContext | relevant_files, findings, assumptions, all default [] |
{
"task_id": "6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19",
"summary": "Add a GitHub OAuth sign-in flow next to the existing password login.",
"requirements": [
{
"id": "R1",
"description": "A 'Sign in with GitHub' button starts the OAuth flow.",
"kind": "functional",
"priority": 1,
"acceptance": ["GET /auth/github redirects to github.com/login/oauth/authorize"]
},
{
"id": "R2",
"description": "Password login keeps working unchanged.",
"kind": "constraint",
"priority": 1,
"acceptance": ["existing tests in tests/auth pass"]
}
],
"context": {
"relevant_files": ["src/auth/mod.rs", "src/routes.rs"],
"findings": ["sessions are stored with tower-sessions"],
"assumptions": ["client id and secret come from environment variables"]
},
"body": ""
}
Because every field but task_id and summary has a default, a minimal spec is two keys.
Plan
| Type | Fields |
|---|---|
Plan | task_id, approach (default ""), phases (default []) |
PlanPhase | name, parallel (default false), subtasks (default []) |
Subtask | id, title, description, files, depends_on: Vec<SubtaskId>, verification, status (default pending), attempts (default 0), notes (default "") |
SubtaskStatus | Meaning |
|---|---|
pending (default) | not started |
in_progress | an agent is working on it |
done | implemented |
failed | gave up after retries |
skipped | dependency failed or no longer needed |
Phases always run in order; the subtasks of a phase may run concurrently when parallel is
true. Plan offers subtasks() / subtasks_mut() (plan order), subtask(id), len(),
is_empty() and is_complete() (every subtask done or skipped).
Plan::validate() rejects two kinds of broken plans with an ErrorKind::Config error:
- a
depends_onentry naming a subtask that is not in the plan ("subtask <id> depends on unknown subtask <id>"); - a dependency cycle, found with Kahn’s algorithm: every subtask starts with an in-degree
equal to its number of dependencies, subtasks at zero are dequeued, and each dequeue
decrements the subtasks that depend on it. If fewer subtasks are dequeued than exist, the
remainder forms a cycle (
"plan contains a dependency cycle").
Dependencies may cross phase boundaries; validation looks at the whole plan.
{
"task_id": "6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19",
"approach": "Add an OAuth client module, then wire routes and the login page.",
"phases": [
{
"name": "Foundations",
"parallel": false,
"subtasks": [
{
"id": "0b7e2f4c-1d3a-4e8b-9c6f-5a2d8e1f3b70",
"title": "OAuth client",
"description": "Create src/auth/github.rs exposing authorize_url() and exchange_code() (R1).",
"files": ["src/auth/github.rs", "src/auth/mod.rs"],
"depends_on": [],
"verification": ["cargo test auth::github"],
"status": "done",
"attempts": 1,
"notes": "Uses reqwest already present in Cargo.toml."
}
]
},
{
"name": "Wiring",
"parallel": true,
"subtasks": [
{
"id": "c41a9e07-6b52-4f1d-a3e8-2d9f0b7c6e15",
"title": "Routes",
"description": "Add GET /auth/github and GET /auth/github/callback (R1, R2).",
"files": ["src/routes.rs"],
"depends_on": ["0b7e2f4c-1d3a-4e8b-9c6f-5a2d8e1f3b70"],
"verification": ["cargo test --test auth"],
"status": "pending",
"attempts": 0,
"notes": ""
}
]
}
]
}
QA report
| Type | Fields |
|---|---|
QaReport | task_id, round (1-based), verdict, summary (default), issues (default) |
QaIssue | severity, title, detail, and optional requirement, file, line, suggested_fix |
QaVerdict | approved, changes_requested, inconclusive |
Severity | low < medium < high < critical (ordered) |
issues_at_least(Severity::High) filters by severity; is_blocking() is true for any
verdict other than approved; to_markdown() renders the human-readable report.
Messages
A conversation is a Vec<Message>; each Message has a role (user or assistant) and
a list of ContentBlocks. There is no system role: the system prompt travels separately in
CompletionRequest::system. Tool results are sent back in a user message.
ContentBlock (tag type) | Fields |
|---|---|
text | text |
thinking | text (only when the provider exposes reasoning) |
tool_use | id, name, input (JSON matching the tool schema) |
tool_result | tool_use_id, content (text), is_error (default false) |
{"role": "assistant", "content": [
{"type": "text", "text": "Reading the router first."},
{"type": "tool_use", "id": "toolu_01", "name": "read_file", "input": {"path": "src/routes.rs"}}
]}
Helpers: Message::user, Message::assistant, Message::tool_results, text() (joins text
blocks), tool_uses(), has_tool_use(), and estimate_tokens(), a deliberately rough
4-characters-per-token estimate used for context budgeting without a tokenizer.
Usage counts input_tokens, output_tokens, cache_read_tokens and cache_write_tokens
(the last two default to 0). It supports combined, += and total() (input plus output).
Phase
Phase lists the pipeline stages in canonical order: assess, spec, plan, build,
qa, fix, merge (Phase::ALL). as_str() gives the stable lowercase name used in
[phases.<name>] configuration and CLI flags; FromStr accepts it case-insensitively and
returns a Config error otherwise. The enum does not impose an order at run time: a
pipeline may skip spec or loop fix → qa.
Events
Event is internally tagged with type. Every variant except log carries the run id.
| Variant | Extra fields |
|---|---|
run_started | task |
phase_started | phase |
phase_finished | phase, success, summary |
agent_started | role, subtask (nullable) |
agent_text | role, text |
tool_called | role, tool, input |
tool_returned | role, tool, is_error, duration_ms, preview |
agent_finished | role, steps, usage, stop (the AgentStop serialised as a JSON string) |
subtask_updated | subtask, status |
retrying | what, attempt, delay_ms |
paused | reason |
run_finished | success, status (a TaskStatus) |
log | run (nullable), level (info, warn, error), message |
EventBus::publish wraps the event in an Envelope stamped with the current time; that is
the shape appended to events.jsonl:
{"at":"2026-09-27T08:16:10.002Z","event":{"type":"tool_returned","run":"a3f0…","role":"coder","tool":"read_file","is_error":false,"duration_ms":3,"preview":"use axum::Router;…"}}
Errors
Every crate returns vibe_core::Result<T> with a single Error { kind, message, retry_after, source }. ErrorKind drives behaviour rather than display: RateLimited,
ServerError and Network are the only kinds for which is_retryable() is true;
ContextTooLong ends an agent run with AgentStop::ContextWindow; Denied is what policy
checks return. io::Error converts to Storage, serde_json::Error to InvalidRequest,
toml::de::Error to Config.
JSON is the source of truth
Complexity assessments, specs, plans and QA reports are produced by agents as JSON, parsed
into these types, and saved as JSON. Markdown (Spec::to_markdown, QaReport::to_markdown)
is rendered from the typed value for humans and is never parsed back. There is therefore
one source of truth per artefact, and a malformed model answer is caught at the boundary by
serde instead of deep in the pipeline. The rationale is in
ADR-006; the extraction and repair strategy is in
Agent runtime.
The JSON documents the built-in prompts ask for are the agent-facing shapes, not the
persisted ones: they omit fields only the framework knows. The planner, for example, returns
subtasks without id, status or attempts and names dependencies by subtask title, and
the QA reviewer returns a report without task_id or round. Assigning ids, resolving
titles to SubtaskIds and filling task_id and round is the caller’s job before a value
becomes a Plan or a QaReport and before Plan::validate() runs.
The pipeline
vibe-pipeline turns a task into a sequence of agent sessions. It owns no provider, tool or
workspace: everything concrete is injected, so the same driver runs under the CLI, in tests
with a scripted provider, or inside another host. This page describes the driver as
implemented in crates/vibe-pipeline/src/; the on-disk artefacts it reads and writes are
described in Persistence layout.
Entry points
#![allow(unused)]
fn main() {
let pipeline = Pipeline::new(PipelineDeps { registry, providers, store, workspace,
tools, events, config, project_root, committer });
let report: RunReport = pipeline.run(task_id, RunOptions::default()).await?;
let report = pipeline.resume(task_id).await?; // continue the last run
let report = pipeline.resume_with(task_id, options).await?;
}
PipelineDeps field | Role |
|---|---|
registry: Arc<Registry> | plugins, agent overrides, hooks, memory stores, plugin tools |
providers: Arc<dyn ProviderResolver> | turns a ModelRef into a provider and a model id |
store: Arc<dyn PipelineStore> | tasks, artefacts, run state, memory, event log |
workspace: Arc<dyn WorkspaceProvider> | opens the task workspace, lists changes, merges |
tools: ToolRegistry | built-in tools, merged with the registry’s tools (the registry is cloned first, so a tool in tools wins on a name clash) |
events: EventBus | where every event is published |
config: VibeConfig | the parsed .vibe/config.toml |
project_root: PathBuf | the project directory |
committer: Option<Committer> | commits the workspace after subtasks and fixes |
resetter: Option<Resetter> | discards what a failed attempt left in a shared workspace |
subtask_workspaces: Option<Arc<dyn SubtaskWorkspaces>> | one workspace per subtask attempt (the CLI plugs GitSubtaskWorkspaces for git_worktree) |
RunOptions field | Effect |
|---|---|
complexity_override | forces a complexity; the heuristic and the assessor are skipped |
from_phase | starts at this phase instead of assess, reusing persisted spec and plan |
until_phase | pauses the run once this phase is done |
dry_run | same as until_phase = plan (or an earlier until_phase if one is given) |
cancel | a watch::Receiver<bool>; the run stops when it becomes true |
RunReport carries run_id, the saved task, final_status (a TaskStatus), every
PhaseResult in execution order (phase, success, summary, usage, next), the token
usage and wall-clock duration of this invocation, and the final RunState.
is_success() is true for ready and done.
A phase error does not make run return Err: the run ends as failed, last_error is
recorded in run.json, and a report is returned. Only infrastructure errors come back as
Err: the task cannot be loaded, the store cannot write, or the workspace cannot be opened
(in that last case run_finished is still published with success: false).
Profiles
The complexity picks a Profile (complexity.rs): the phases run after assess, and which
spec steps run.
| Complexity | Phases after assess | Spec steps |
|---|---|---|
trivial | plan, build, qa, merge | none: the planner works from the task description |
simple | spec, plan, build, qa, fix, merge | gatherer only (light spec) |
standard | spec, plan, build, qa, fix, merge | gatherer, writer |
complex | spec, plan, build, qa, fix, merge | gatherer, researcher, writer, critic |
fix is never reached by walking the profile forward; only a QA verdict jumps to it.
The complexity is settled in this order:
RunOptions::complexity_override(vibe run --complexity);- the complexity already stored on the task (a task assessed once keeps its class);
heuristic_complexity: at most 30 words across title and description and a known small-change pattern. Typos, version or dependency bumps, colour, text, label, wording, title or copy changes and copyright-year updates aretrivial; renames, removal of unused code, lint or compiler warning fixes and “add a comment” aresimple;- the
complexity_assessoragent. Aliases are accepted (low,easy→ simple,medium→ standard,hard,high→ complex, …). Itsneeds_researchandneeds_critiqueflags switch on the researcher and critic, but only for profiles that write a full spec (standard and complex); standard, when the assessor fails or answers an unknown class.
A run that starts after assess (from_phase) reuses the profile of the previous run when
there is one and no override is given; otherwise it applies steps 1–3 and 5 without calling
the assessor. default_profile(task, override) exposes the same computation for displays.
Flow
┌────────┐ ┌──────┐ ┌──────┐ ┌───────┐ ┌────┐ approved ┌───────┐
task ────▶ │ assess │──▶│ spec │──▶│ plan │──▶│ build │──▶│ qa │──────────▶│ merge │──▶ ready / done
└────────┘ └──────┘ └──────┘ └───────┘ └────┘ └───────┘
(trivial │ │ ▲
skips it) │ │ │ changes requested
│ ▼ │
no subtask done ┌─────┐
│ │ fix │
▼ └─────┘
failed qa: round limit, inconclusive,
no fix phase; fix: same issue 3×
──▶ paused, task in review
For every phase the driver:
- checks the cancel token (cancelled → run cancelled);
- sets the task status for the phase and saves
task.jsonandrun.json; - runs the
before_phasehooks; anAbortends the run as cancelled with the reason inlast_errorand in the progress notes; - publishes
phase_started, runs the phase, publishesphase_finished; - runs the
after_phasehooks and appends the phase tocompleted_phases; - follows the phase’s
Transition:Continue(next phase of the profile),Goto(QA ⇄ fix) orStop { status, reason, pause }; - applies
until_phase, then savesrun.jsonwith the next phase ascurrent_phase.
Each agent is resolved with RunContext::agent_spec: the registry’s agent for the role
(plugins and .vibe/agents/*.toml overrides) or the built-in one. When
[phases.<current phase>] exists in the configuration, its thinking replaces the agent’s
thinking level. The model is the agent’s fixed model, else config.model_for(phase). Tools
run with ToolContext rooted at the workspace and the permissions read, write and execute,
plus network when security.allow_network is set.
Kickoff messages
The first user message of each session is built by kickoff_for from delimited sections.
When the agent’s system prompt already consumes a section’s placeholder ({{spec}},
{{plan}}, …), the section is replaced by a one-line pointer, so the text is not sent twice;
custom prompts without placeholders still receive everything.
| Role | TASK | SUBTASK | SPEC | PLAN, PROGRESS NOTES | CHANGES | QA REPORT | MEMORY | PRIOR CONTEXT |
|---|---|---|---|---|---|---|---|---|
complexity_assessor | yes | yes (empty) | ||||||
spec_gatherer, spec_researcher, spec_writer | yes | yes | yes | |||||
spec_critic | yes | yes | yes | yes | ||||
planner | yes | yes | yes | yes | ||||
coder, coder_recovery | yes | yes | yes | yes | yes | yes | ||
qa_reviewer | yes | yes | yes | yes | yes | yes | ||
qa_fixer | yes | yes | yes | yes | yes |
A final INSTRUCTIONS section restates the JSON document the role must end with.
PRIOR CONTEXT carries: the gatherer JSON and research report for later spec steps; the
rejection reason for a planner retry; the subtask’s failure history for a coder; a summary
of earlier rounds for QA and the fixer. Sizes are capped: prior context 12 000 characters,
progress notes 8 000 (most recent kept), memory 6 000, workspace changes 20 000 (beginning
and end kept), spec and plan 24 000. The memory section is the task’s memory/*.md plus up
to 10 entries recalled from each registered MemoryStore.
The phases
assess records the complexity on the task, the profile in run.json, and a progress
note naming the source (override, stored on the task, heuristic, assessor …,
fallback …).
spec runs the gatherer (mandatory: its failure fails the phase), then the researcher,
writer and critic as the profile says. Their failures are logged as warnings and the best
spec so far is kept. The writer also writes a markdown copy inside the workspace at
.vibe/specs/<task short id>-spec.md. A critic verdict revised with a non-empty spec
replaces the spec. An empty spec (no summary, no requirement) fails the phase. The spec’s
context.findings are appended to memory/patterns.md.
plan runs the planner and converts its output with plan_from_output: subtask ids are
generated, depends_on entries are resolved by title (case-insensitive) or 1-based position,
and the plan is validated (no empty or duplicate title, no self or unknown dependency, no
cycle, at least one subtask). A flat subtasks list becomes one sequential phase called
Implementation. An invalid plan is retried with the error as feedback, up to
max_phase_retries more attempts, each announced by a retrying event.
build is the scheduler below. qa and fix are the loop below.
merge stops the run in all cases:
| Situation | Final task status |
|---|---|
auto_merge = false | ready |
| merged (fast-forward or merge commit), or nothing to merge | done |
MergeOutcome::NeedsHumanReview { files } | ready, conflicting files in progress.md |
| merge error (for example a dirty project checkout) | ready, error in progress.md |
The conflict strategy is a property of the workspace provider the host builds; the pipeline
only records pipeline.merge_strategy in the progress notes.
Build scheduler
A pending subtask is ready when every subtask in its depends_on is done, every
subtask of earlier plan phases is finished (done, failed or skipped), and, in a phase
whose parallel is false, the previous subtask of the same phase is finished. Up to
max_parallel_subtasks ready subtasks run at once, each in a fresh session spawned on its
own tokio task (sessions are aborted if the build ends early or its future is dropped).
Coder sessions use run_with_continuation, so a session that fills its context window is
summarised and continued.
A session succeeds when it ends with a JSON report whose status is done (or success,
completed, ok). Anything else is a failed attempt: the reason is appended to the
subtask’s notes, which the next attempt receives as prior context. The attempt numbered
max_subtask_attempts uses coder_recovery instead of coder (when more than one attempt
is allowed). After the last attempt the subtask is failed, a line is added to
memory/gotchas.md, and every pending subtask depending on it through depends_on,
transitively, becomes skipped. Failures do not skip later phases by ordering alone.
Pending subtasks that can never become ready are skipped as “blocked by the plan ordering”.
Isolated subtasks. When subtask_workspaces is set and pipeline.isolate_subtasks is
true, launch opens a workspace labelled s<N>-a<attempt> forked from the task workspace
and the session’s tools are confined to it (RunContext::runner_in). The scheduler awaits
one finished session, drains every other session that already finished (now_or_never),
and handles the batch in plan order. A successful attempt is integrated right away with
SubtaskWorkspaces::integrate; a Conflict or an error turns it into a failed attempt with
the reason in its notes. Every attempt workspace is discarded after its outcome is known;
discard_all runs when the build starts (after a vibe: checkpoint before build commit)
and when it ends or is cancelled. Nothing is deferred: the task workspace only changes
through integrations, which the scheduler serialises.
Shared workspace. Otherwise commits are deferred: the committer stages the whole
workspace, so completed subtasks are committed only when no other session is running,
possibly several in one commit (vibe: complete subtask 2 - Routes,
vibe: complete subtasks 2, 3 - Routes; Login page). A failed commit is logged and noted,
never fatal.
The build continues to QA when at least one subtask is done and none is pending; its
success flag is false if any subtask failed or was skipped. Otherwise the run fails with
build failed: 0 done, 2 failed, 1 skipped. Subtasks found in_progress when the build
starts (a crash) go back to pending.
QA loop
Each QA invocation increments qa_round in run.json, gives the reviewer the workspace
change summary and the titles of earlier rounds, and saves qa_report_<round>.json/.md. A
reviewer that fails to produce valid JSON after repair and resume yields an inconclusive
report.
| Verdict | Next |
|---|---|
approved | merge |
changes_requested, profile without fix (trivial) | pause, task review |
changes_requested, max_qa_rounds reviews already done in this invocation | pause, task review |
changes_requested otherwise | fix |
inconclusive (including unknown verdict words) | pause, task review |
The fixer first checks for escalation: if one issue title (lowercased, whitespace
collapsed) appears in each of the last 3 reports, it records the issue in
memory/gotchas.md and pauses the run with the task in review. Otherwise the fixer runs,
the workspace is committed as vibe: address QA round <n>, and QA runs again. A pause
during fix leaves current_phase = qa so a resumed run reviews before fixing again. The
round limit counts reviews of the current invocation only, so each resume gets a fresh
budget, while qa_round keeps numbering reports across invocations.
Statuses
| Phase running | Task.status |
|---|---|
| assess, spec, plan | planning |
| build | building |
| qa, fix, merge | review |
| End of run | Task.status | RunStatus |
|---|---|---|
merge, auto_merge off, conflicts or merge error | ready | finished |
| merge succeeded or nothing to merge | done | finished |
| QA pause or escalation | review | paused |
until_phase or dry run reached | backlog | paused |
| phase error, build with nothing done | failed | failed |
| cancel token, hook abort | cancelled | cancelled |
A RunStatus of running in run.json after the process ended means it died mid-phase.
Resume
resume loads run.json and refuses when there is none or its status is finished
(start a new run). running, paused, failed and cancelled are resumable. The run
keeps its run_id and profile, restarts at current_phase (or from_phase if given),
clears last_error, and reuses the persisted spec and plan. Subtasks already done are
never redone; interrupted ones are retried. When a failed run resumes at build, failed
and skipped subtasks go back to pending with their attempt counters reset, and a progress
note says how many.
Run manager and locking
RunManager is the seam interfaces use (ADR-007):
start and resume spawn a run on its own tokio task and return a RunHandle to await,
cancel flips that run’s cancellation token, active lists running tasks and subscribe
returns the live event stream. vibe run goes through it; the terminal UI runs several
tasks at once with it.
Pipeline::execute first calls PipelineStore::lock_run: FileTaskStore takes an
exclusive OS lock on run.lock in the task directory (and writes its pid to run.owner), so
one process at a time runs a task, and a crashed process leaves no stale lock. The run’s
cancellation token combines the caller’s token with PipelineStore::take_cancel_request,
polled every 500 ms: vibe cancel writes cancel.request, which the running process
consumes. Index updates take .index.lock in the tasks directory as well as the in-process
mutex, so a CLI and another process never interleave them.
Approval gates
pipeline.approvals lists ApprovalGates. Before a phase runs, the driver computes the
gate it must pass (Plan needs Spec when a spec exists, Build needs Plan, Merge
needs Merge). A gate without a valid approval sets RunState::pending_approval,
publishes approval_requested once, then paused, and ends the run as paused (task
review) at that phase. RunState::resolve_approval, called by vibe approve and
vibe reject, records the decision. On resume an approved gate lets the phase run; a
rejected one redirects to the producer: Spec and Plan take the reason through
RunContext::take_rejection and put it in their prompt, Merge becomes a QA report with
one high severity “Human review” issue and forces the fix phase (pending_human_fix), even
in profiles without it. A completed Spec revokes the spec and plan approvals, Plan the
plan approval, Build and Fix the merge approval.
Budgets
run.json accumulates the tokens (usage) and active milliseconds (active_ms) of every
invocation of a run. The driver builds a vibe_core::RunBudget from those totals and the
pipeline.max_tokens / pipeline.max_duration_secs limits, stores it in the RunContext
and hands it to every AgentRunner. The runner adds each model call’s usage to it and
treats a reached limit like a cancellation: no new step starts. RunContext::is_cancelled
is true as well, so the build scheduler stops launching sessions and puts interrupted
subtasks back to pending. The driver tells the two apart with RunBudget::exceeded: a
cancelled phase whose budget is exhausted ends the run as paused (task backlog) at the
same current_phase, and the budget is also checked before every phase. The totals are
written to run.json before and after each phase and at the end of the run, so a crash
loses at most the accounting of the phase in progress.
run with from_phase is different: it starts a new run id at that phase, keeping only the
previous profile. It is how you re-run QA after fixing things by hand (--from qa).
Events
The driver publishes run_started, phase_started, phase_finished, subtask_updated
(in_progress, done, pending after a failed attempt, failed, skipped), retrying
(plan retries with delay_ms: 0, subtask retries), paused and run_finished
(success true for ready and done); agent sessions add their own events (see
Agent runtime). An internal router forwards each event carrying a
run id to that run’s sink from the store, which appends it to events.jsonl.
Seams
ProviderResolver maps a ModelRef to (Arc<dyn ModelProvider>, model id). An empty
model (ollama/) means the provider’s default. RegistryResolver looks providers up by
name in a Registry; the CLI adapts its provider registry, tests plug a scripted provider.
Committer is Arc<dyn Fn(PathBuf, String) -> BoxFuture<'static, Result<Option<String>>>>:
workspace root and message in, commit id (or None when there was nothing to commit) out.
Without a committer, nothing is committed during the run and the worktree provider commits
leftovers as vibe: checkpoint at merge time (see
Workspaces and merging).
Agent runtime
An agent is data (AgentSpec); vibe-agents executes it. One runtime, AgentRunner, runs
every agent the same way: it renders the system prompt, offers the selected tools, calls the
model, executes the tools the model asks for, feeds the results back, and stops on a fixed
set of conditions. This page describes the spec, the loop, and the helpers built on top of
it (structured output and continuation).
AgentSpec
| Field | Type | Default | Meaning |
|---|---|---|---|
role | AgentRole | required | built-in role or Custom(String); serialised as a snake_case string |
description | String | "" | human-readable purpose |
system_prompt | String | required | a PromptTemplate with {{variable}} placeholders |
tools | ToolSelection | all | which registered tools are offered |
model | ModelSelection | phase | model to use |
thinking | ThinkingLevel | medium | reasoning budget |
max_steps | u32 | 200 | maximum model round trips |
structured_output | bool | false | the agent must end with a JSON document |
max_tokens | u32 | 8192 | output token budget per step |
Builders: AgentSpec::new(role, prompt), with_tools, with_thinking, with_max_steps,
with_structured_output, with_description.
ToolSelection
ToolSelection::resolve(available) turns the selection into concrete names, always
intersected with what the runner’s ToolRegistry actually contains and in registry order:
| Variant | TOML / JSON | Resolves to |
|---|---|---|
None | "none" | nothing |
ReadOnly | "read_only" | available names in READ_ONLY_TOOLS (read_file, glob, grep, list_dir, web_fetch, web_search) |
ReadWrite | "read_write" | ReadOnly plus write_file |
All | "all" | every available tool, including plugin tools |
Named(names) | {"named": [...]} | the listed names that exist; unknown names are dropped silently |
ThinkingLevel
| Level | budget() | Anthropic (adaptive / budget form) | OpenAI-compatible reasoning_effort |
|---|---|---|---|
off | None | thinking not sent | not sent |
low | 1 024 | effort low / budget_tokens: 1024 | low |
medium | 4 096 | effort medium / budget_tokens: 4096 | medium |
high | 16 384 | effort high / budget_tokens: 16384 | high |
max | 32 768 | effort max / budget_tokens: 32768 | high |
The Anthropic provider picks the form from the model id (ThinkingMode::for_model): adaptive
thinking for Claude 4.6 and later, a token budget for older models.
When a level has a budget, the runner raises the step’s output budget to at least
budget + 4096 (effective_max_tokens), because providers with extended thinking require
the output budget to exceed the thinking budget.
ModelSelection
phase (the default) uses the model the caller configured for the current pipeline phase;
{"fixed": {"provider": "anthropic", "model": "claude-opus-5"}} pins one. The runner itself
receives a concrete provider and model id; honouring ModelSelection is the caller’s
responsibility when it builds the runner.
Building a runner
use std::sync::Arc;
use vibe_agents::{AgentRunner, builtin_agent};
use vibe_core::{AgentRole, EventBus, Permissions, Registry, ToolContext, ToolRegistry};
let runner = AgentRunner::new(provider, "claude-sonnet-5".into(), tools, Arc::new(registry), EventBus::default())
.context_window(200_000)
.task(task.clone())
.tool_context(ToolContext::new(&workspace.root))
.permissions(Permissions::local())
.var("spec", spec.to_markdown())
.cancel_token(cancel_rx);
let spec = builtin_agent(&AgentRole::Coder).expect("built-in");
let outcome = runner.run(&spec, "Implement subtask 2.".into()).await?;
| Builder | Default | Effect |
|---|---|---|
context_window(tokens) | DEFAULT_CONTEXT_WINDOW = 200 000 | basis of the 85 % / 90 % thresholds |
run_id(id) | fresh RunId | attached to every event |
task(task) | none | provides task_title, task_description, task_id for tools and hooks |
subtask(id) | none | reported in agent_started |
tool_context(ctx) | current directory | workspace root and permissions of tool calls |
permissions(p) | Permissions::default() (local, no network) | applied to the tool context |
max_tool_output_chars(n) | DEFAULT_MAX_TOOL_OUTPUT_CHARS = 100 000 | truncation threshold |
var / extra_vars | none | prompt variables; override the built-in ones |
cancel_token(rx) | none | tokio::sync::watch::Receiver<bool> |
max_provider_retries(n) | 2 | runner-level retries of retryable provider errors |
retry_base_delay(d) | 1 s | base of the runner’s exponential backoff |
A runner is cheap to clone; every run starts a fresh conversation, and resume(spec, history, message) continues an existing transcript.
The loop
Before the first step the runner renders the system prompt (template plus the
augment_prompt output of every hook, each separated by a blank line), resolves the tool
selection, estimates the size of the tool schemas, sets ctx.agent to the role name and
ctx.task_id from the task, and publishes agent_started.
loop:
cancelled? ─ yes ─▶ stop Cancelled
steps >= max_steps? ─ yes ─▶ stop MaxSteps
converging agent, steps >= ceil(75% max)? ─ once ─▶ append CONVERGE_MESSAGE
estimate >= 90% of context window? ─ yes ─▶ stop ContextWindow
estimate >= 85% of context window? ─ once ─▶ append CONTEXT_WARNING_MESSAGE
complete (with runner retries)
ContextTooLong ─▶ stop ContextWindow
Cancelled ─▶ stop Cancelled
other error ─▶ stop Error{kind, message}
steps += 1, usage += response.usage
publish agent_text (if the answer has text)
tool calls? yes ─▶ execute, append assistant + one user message of results, loop
no ─▶ stop_reason MaxTokens and not yet nudged ─▶ append CONTINUE_NUDGE, loop
otherwise ─▶ stop Completed
The estimate is Message::estimate_tokens over the system prompt and transcript plus the
tool schemas (4 characters per token). Injected messages are merged into the last user
message when there is one, because providers reject two consecutive user turns.
| Constant | Value |
|---|---|
CONTEXT_WARNING_PERCENT | 85 |
CONTEXT_STOP_PERCENT | 90 |
CONVERGE_STEP_PERCENT | 75 |
CONTEXT_WARNING_MESSAGE | “You are near the context limit: finish now, write your final output.” |
CONVERGE_MESSAGE | “Converge now: produce your final structured answer.” |
CONTINUE_NUDGE | “continue (your previous answer was cut off by the output limit; resume exactly where it stopped)” |
A converging agent is any agent with structured_output = true, plus qa_reviewer and
spec_critic. Coders are never told to converge.
Stop conditions
AgentStop (tag reason) | Cause | is_success() |
|---|---|---|
completed | the model ended a turn without tool calls | yes |
max_steps | max_steps round trips done | no |
context_window | 90 % threshold reached, or the provider answered ContextTooLong | no |
cancelled | cancel token set, or cancelled during a retry wait | no |
error (kind, message) | non-retryable provider error or retries exhausted | no |
run returns Err only for failures outside the conversation; a provider failure is an
AgentStop::Error inside Ok(AgentOutcome), so the transcript is never lost.
AgentOutcome holds role, stop, final_text (text of the last assistant message),
messages, steps, tool_calls and usage.
Tool execution
The calls of one step are split into runs. Consecutive calls to non-mutating tools
(Tool::is_mutating() == false) run concurrently with join_all; a call to a mutating tool
runs alone, after everything before it and before everything after it. Results are always
returned in the order of the calls, as one user message of tool_result blocks.
For each call:
- publish
tool_called; - unknown tool: error result
Unknown tool `x`. Available tools: …; - otherwise run every hook’s
before_toolin registration order; the firstAbortwins and yields an error result telling the model not to repeat the call; - otherwise call the tool, catching both
Err(Tool `x` failed: …) and panics (Tool `x` crashed: …), then run every hook’safter_tool; - truncate the output if needed and publish
tool_returnedwith a 200-character preview.
Tool failures never stop the run: the model sees them and can adapt.
Truncation and .vibe/tool-output/
An output longer than max_tool_output_chars characters is cut to that length. The full
text is written to <workspace_root>/.vibe/tool-output/<uuid>.txt and a note is appended:
[Output truncated: showing the first N of M characters. The full output was saved to `…`; read it in smaller pieces if you need more.]. If the file cannot be written, the note
says so instead.
Trace of tool outputs
Independently of that truncation, a runner given a ToolTrace (dir, reference,
max_chars) writes the complete output of every call to <dir>/<call>.txt, cut at
max_chars characters with a final marker line, and reports it in tool_returned
(output_file = <reference>/<call>.txt, output_chars = the untruncated length). The
pipeline passes .vibe/tool-output/<task dir>/<run>/ of the project when
pipeline.trace_outputs is on. The call id is generated before the call and repeated on
the return, so parallel read-only calls pair even when they return out of order;
exit_code and timed_out are copied from the tool’s metadata (bash). A trace file that
cannot be written is logged as a warning and leaves output_file empty; the call itself is
not affected. vibe trace and the read layer’s trace module read the store back
(Tool call trace, ADR-008).
Provider errors and retries
Providers retry transient failures themselves (see Providers and
models). The runner adds a last line of defence: a retryable error
(RateLimited, ServerError, Network) is retried up to max_provider_retries times,
waiting the provider’s retry_after when present, else retry_base_delay * 2^(attempt-1).
Each retry publishes retrying. The cancel token is checked after each wait.
Events
| Event | When |
|---|---|
agent_started | before the first step |
agent_delta | while a step streams: text or reasoning pieces, merged when several arrive together; ephemeral (not in events.jsonl) |
agent_text | after each step whose answer contains text |
tool_called / tool_returned | around each tool call |
retrying | before each runner-level retry |
agent_finished | at the end, with steps, usage and the serialised stop |
Cancellation
Cancellation is cooperative: the watched value is checked before every step and after each
retry delay. A tool call already in flight completes; the run then stops with cancelled
before the next model call.
Structured output
Agents with structured_output = true end their answer with a JSON document.
run_structured::<T>(runner, spec, message) runs the agent and deserialises it into any
serde type, escalating from cheap to expensive:
extract_json(final_text): the whole trimmed text; else the last fenced block taggedjson; else the last untagged fence; else balanced top-level{…}/[…]spans from last to first (brackets inside strings are skipped). Only objects and arrays are accepted.repair_json: one completion without tools, temperature 0, at most 8 192 output tokens, asking the model to return only valid JSON for a schema hint (the type name ofT, or the hint given torun_structured_with_hint).- If the run was
cancelledor ended inerror, stop here with that error. - Resume the agent once with
Your previous answer was not valid JSON: <error>. Return only JSON., then parse again. Steps, tool calls and usage of both runs are summed.
A second failure returns ErrorKind::InvalidRequest. What the caller does with it (for QA,
count the round as inconclusive) is the caller’s policy.
Continuation
run_with_continuation(runner, spec, message, policy) handles long sessions. While a run
stops with context_window and fewer than policy.max_continuations continuations happened
(default 5), it summarises the transcript and starts a fresh run whose first message is the
original message followed by Summary of your previous session: and Continue where you left off.
summarize_transcript renders the transcript as plain text (tool inputs cut to 400
characters, results to 1 500, the whole to the most recent 300 000 characters) and asks for
handover notes of at most summary_max_words (default 600) words with the sections Goal,
Done so far, Key findings, Problems, Next steps. If summarisation fails, the last outcome is
returned as is. The combined outcome appends transcripts and sums counters.
Built-in agents
| Role | Tools | Thinking | Structured | max_steps | max_tokens |
|---|---|---|---|---|---|
complexity_assessor | read_only | low | yes | 30 | 8 192 |
spec_gatherer | read_only | medium | yes | 100 | 16 384 |
spec_researcher | read_only | medium | no | 100 | 16 384 |
spec_writer | read_write | high | yes | 100 | 32 000 |
spec_critic | read_only | high | yes | 60 | 32 000 |
planner | read_only | high | yes | 100 | 32 000 |
coder | all | low | no | 300 | 16 384 |
coder_recovery | all | medium | no | 300 | 16 384 |
qa_reviewer | all | high | yes | 200 | 32 000 |
qa_fixer | all | medium | no | 200 | 16 384 |
merge_resolver | none | low | no | 3 | 32 000 |
commit_message | none | low | no | 2 | 4 096 |
Every built-in agent uses model = phase. Prompts live in
crates/vibe-agents/src/prompts/<role>.md and are embedded with include_str!.
Prompt variables
Each prompt starts with an HTML comment listing its variables; strip_doc_comment removes it
before rendering and documented_variables parses it. The runner always provides the
COMMON_VARIABLES task_title, task_description, workspace_root and date
(YYYY-MM-DD); the rest come from AgentRunner::var. Unknown placeholders render as empty
strings.
| Variable | Used by |
|---|---|
spec | spec_critic, planner, coder, coder_recovery, qa_reviewer, qa_fixer, merge_resolver |
plan | coder, coder_recovery, qa_reviewer |
subtask | coder, coder_recovery, commit_message |
progress | coder, coder_recovery, qa_reviewer |
memory | spec_gatherer, spec_researcher, spec_writer, planner, coder, coder_recovery, qa_reviewer, qa_fixer |
prior_context | every role except commit_message |
qa_report | qa_fixer |
spec_path | spec_writer |
file_path, conflict | merge_resolver |
diff | commit_message |
commit_message and merge_resolver do not document workspace_root; commit_message
does not document date either.
TOML overrides
load_agent_overrides(dir) reads every *.toml file of a directory (normally
project_agents_dir(root) = .vibe/agents) in file-name order; apply_agent_overrides
applies them to a Registry. Unknown keys are rejected (deny_unknown_fields). A file
naming an existing role changes only the keys it sets; any other role defines a new agent and
must provide system_prompt or system_prompt_file (relative to the TOML file), never both.
role = "qa_reviewer"
model = "anthropic/claude-opus-5" # or "phase", or {fixed = {provider = "…", model = "…"}}
thinking = "max"
max_steps = 250
tools = ["read_file", "grep", "glob", "bash"] # or "none" | "read_only" | "read_write" | "all"
system_prompt_file = "qa_reviewer.md"
max_steps and max_tokens are clamped to at least 1. The user-facing guide is
Customising agents.
Extension points
Everything replaceable in Vibe Factory is a small trait in vibe-core, and every
implementation, built-in or third-party, is collected in one Registry
(ADR-002). This page gives, for each trait, its
signature (abridged), the contract implementations must honour, a minimal example and the way
it reaches the registry. Agents are not a trait but data (AgentSpec); they are covered at
the end, together with the EventBus.
All traits are Send + Sync and async methods use #[async_trait::async_trait].
Implementations are shared as Arc<dyn Trait> (SharedProvider, SharedTool,
SharedWorkspaceProvider, SharedMemory, SharedTaskStore, SharedHook), so they must be
safe to call from several tasks at once. Errors are vibe_core::Error; pick the ErrorKind
that matches the failure, because callers branch on it.
The Registry
pub struct Registry {
pub tools: ToolRegistry, // by name, insertion order
pub providers: BTreeMap<String, SharedProvider>,
pub agents: BTreeMap<String, AgentSpec>, // by role name
pub workspaces: BTreeMap<String, SharedWorkspaceProvider>,
pub memories: BTreeMap<String, SharedMemory>,
pub hooks: Vec<SharedHook>, // registration order
}
| Method | Behaviour |
|---|---|
add_tool, add_provider(name, p), add_agent, add_workspace, add_memory(name, m) | insert, replacing an entry with the same key |
add_hook | append; hooks never replace each other |
agent(&role), provider(name) | lookups |
before_phase, before_tool | run hooks in order; the first Abort(r) wins and is returned as Abort("<hook name>: r") |
after_phase, after_tool | run every hook |
augment_prompt(role, task) | collect every non-None augmentation, in hook order |
Because later registrations replace earlier ones, registration order is precedence. Within
PluginHost::register_all, native plugins register before out-of-process plugins, so a
remote plugin’s tool overrides a native one of the same name (a warning is logged).
ModelProvider
#[async_trait]
pub trait ModelProvider: Send + Sync {
fn info(&self) -> ProviderInfo; // name, supports_tools, supports_thinking, default_model
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse>;
async fn health_check(&self) -> Result<()> { Ok(()) }
}
Contract.
- Accept the provider-neutral request:
system,messagesofContentBlocks,tools,max_tokens,temperature,thinking_budget,stop_sequencesand opaqueextrafields. Never leak a vendor wire format out of the provider. - An empty
request.modelmeans “your default model”. - Retry transient failures yourself. Return
RateLimited(withretry_afterwhen known),ServerErrororNetworkonly once retries are exhausted. - Map failures precisely:
AuthFailedfor missing or rejected credentials (report a missing key on the first call, not at construction),ContextTooLongwhen the prompt does not fit (the runner turns it intoAgentStop::ContextWindow),InvalidRequestfor anything the caller must fix, including billing problems. - Return tool calls as
ContentBlock::ToolUsewith a uniqueidandStopReason::ToolUse. - Scrub secrets from error messages (
vibe_providers::scrub_secretsdoes it).
Example.
#![allow(unused)]
fn main() {
use vibe_core::provider::ProviderInfo;
use vibe_core::{CompletionRequest, CompletionResponse, Message, ModelProvider, Result, StopReason, Usage};
/// Answers every request with the last user text, reversed.
struct Reverse;
#[async_trait::async_trait]
impl ModelProvider for Reverse {
fn info(&self) -> ProviderInfo {
ProviderInfo { name: "reverse".into(), supports_tools: false,
supports_thinking: false, default_model: "reverse-1".into() }
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
let last = request.messages.last().map(Message::text).unwrap_or_default();
let message = Message::assistant(last.chars().rev().collect::<String>());
Ok(CompletionResponse { message, stop_reason: StopReason::EndTurn,
usage: Usage::default(), model: "reverse-1".into() })
}
}
}
Registration. registry.add_provider("reverse", Arc::new(Reverse)). To make a new
kind usable from [providers.<name>], teach the provider registry a factory:
ProviderRegistry::builder().register_kind("reverse", |_name, _cfg| Ok(Arc::new(Reverse) as SharedProvider)).build(&config)?,
then install_into(&mut registry).
Tool
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str; // unique, snake_case
fn description(&self) -> &str; // shown to the model
fn input_schema(&self) -> serde_json::Value; // JSON schema of the input object
fn is_mutating(&self) -> bool { false }
async fn call(&self, ctx: &ToolContext, input: serde_json::Value) -> Result<ToolOutput>;
fn spec(&self) -> ToolSpec { /* name + description + schema */ }
}
Contract.
- Resolve every user-supplied path with
ctx.resolve_path(path), which normalises.and.., resolves symlinks of the existing prefix and returnsDeniedwhen the path escapesctx.workspace_root(or the allowedextra_read_paths). - Honour
ctx.permissions(read,write,execute,network) and returnError::denied(…)otherwise. - Return
truefromis_mutatingif the tool writes files or runs arbitrary commands. The runner runs consecutive non-mutating calls concurrently, so a wrongfalsecreates races. - Prefer
ToolOutput::error("sentence the model can act on")for expected failures. AnError a panic is caught by the runner and reported asTool `x` failed/crashed: …, but a precise sentence helps the model more. ToolOutput::metadatais for observers and is never shown to the model.
Example.
#![allow(unused)]
fn main() {
use serde_json::{Value, json};
use vibe_core::{Error, Result, Tool, ToolContext, ToolOutput};
/// Counts the words of a file inside the workspace.
struct WordCount;
#[async_trait::async_trait]
impl Tool for WordCount {
fn name(&self) -> &str { "word_count" }
fn description(&self) -> &str { "Count the words of a text file." }
fn input_schema(&self) -> Value {
json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})
}
async fn call(&self, ctx: &ToolContext, input: Value) -> Result<ToolOutput> {
if !ctx.permissions.read {
return Err(Error::denied("word_count needs read permission"));
}
let Some(path) = input["path"].as_str() else {
return Ok(ToolOutput::error("missing string argument `path`"));
};
let full = ctx.resolve_path(path)?;
let text = tokio::fs::read_to_string(&full).await?;
Ok(ToolOutput::ok(text.split_whitespace().count().to_string()))
}
}
}
Registration. registry.add_tool(Arc::new(WordCount)). Agents see it when their
ToolSelection is all or names it.
WorkspaceProvider
#[async_trait]
pub trait WorkspaceProvider: Send + Sync {
fn name(&self) -> &str; // e.g. "git_worktree"
async fn open(&self, project_root: &Path, task: &Task) -> Result<Workspace>;
async fn merge(&self, workspace: &Workspace) -> Result<MergeOutcome>;
async fn merge_validated(&self, workspace: &Workspace,
validator: &mut dyn MergeValidator) -> Result<MergeOutcome>; // default: error
async fn discard(&self, workspace: &Workspace) -> Result<()>;
async fn changes(&self, _workspace: &Workspace) -> Result<String> { Ok(String::new()) }
}
Contract.
openis idempotent: calling it again for the same task returns the same workspace, so an interrupted run can resume.Workspace::rootis the directory agents are confined to; it becomesToolContext::workspace_root.mergereturnsMerged { commit },NoChanges, orNeedsHumanReview { files }. It must not leave the project half-merged: abort and restore before reporting conflicts. UseError::workspacefor failures that are not conflicts.discardis idempotent; already-missing resources are not an error.changesis a human-readable summary for reviewers.
When mandatory validation commands are configured, the pipeline calls merge_validated.
Implementations must prepare the combined candidate without changing the target, call
validator.validate(&candidate).await, reject candidate/target changes during validation,
and publish exactly the checked result. Validation errors must leave the target unchanged.
The default implementation returns an error; it never calls the unchecked merge method.
The in-place implementation validates the existing workspace and has no rollback boundary.
Example. A provider handing every task a plain scratch directory, merged by hand:
#![allow(unused)]
fn main() {
use std::path::Path;
use vibe_core::{MergeOutcome, Result, Task, Workspace, WorkspaceKind, WorkspaceProvider};
struct Scratch;
#[async_trait::async_trait]
impl WorkspaceProvider for Scratch {
fn name(&self) -> &str { "scratch" }
async fn open(&self, project_root: &Path, task: &Task) -> Result<Workspace> {
let root = project_root.join(".vibe").join("scratch").join(task.slug());
tokio::fs::create_dir_all(&root).await?;
Ok(Workspace { root, project_root: project_root.to_path_buf(),
kind: WorkspaceKind::Custom("scratch".into()), branch: None, base_branch: None })
}
async fn merge(&self, _ws: &Workspace) -> Result<MergeOutcome> {
Ok(MergeOutcome::NeedsHumanReview { files: vec![] })
}
async fn discard(&self, ws: &Workspace) -> Result<()> {
let _ = tokio::fs::remove_dir_all(&ws.root).await; // already gone is fine
Ok(())
}
}
}
Registration. registry.add_workspace(Arc::new(Scratch)) (keyed by name()), selected
with pipeline.workspace = "scratch". The built-in providers come from
vibe_workspace::provider_by_name.
MemoryStore
#[async_trait]
pub trait MemoryStore: Send + Sync {
async fn remember(&self, entry: MemoryEntry) -> Result<()>;
async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryEntry>>;
async fn all(&self) -> Result<Vec<MemoryEntry>>;
}
Contract. recall returns at most limit entries, most relevant first, with an
implementation-defined ranking; all returns every entry newest first. A MemoryEntry has a
kind (gotcha, pattern, decision, discovery, dead_end, or other), content,
files, tags, optional task_id and recorded_at. Use Error::storage for back-end
failures. The reference implementation is InMemoryStore (term-overlap ranking).
Example. A store that keeps entries in memory and matches by substring:
#![allow(unused)]
fn main() {
use vibe_core::{MemoryEntry, MemoryStore, Result};
struct Recent(tokio::sync::Mutex<Vec<MemoryEntry>>);
#[async_trait::async_trait]
impl MemoryStore for Recent {
async fn remember(&self, entry: MemoryEntry) -> Result<()> { self.0.lock().await.push(entry); Ok(()) }
async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryEntry>> {
let v = self.0.lock().await;
Ok(v.iter().rev().filter(|e| e.content.contains(query)).take(limit).cloned().collect())
}
async fn all(&self) -> Result<Vec<MemoryEntry>> { Ok(self.0.lock().await.iter().rev().cloned().collect()) }
}
}
Registration. registry.add_memory("recent", Arc::new(Recent(Default::default()))).
TaskStore
#[async_trait]
pub trait TaskStore: Send + Sync {
async fn save_task(&self, task: &Task) -> Result<()>;
async fn load_task(&self, id: TaskId) -> Result<Task>;
async fn list_tasks(&self) -> Result<Vec<Task>>; // newest first
async fn delete_task(&self, id: TaskId) -> Result<()>; // with every artefact
async fn save_spec(&self, spec: &Spec) -> Result<()>;
async fn load_spec(&self, id: TaskId) -> Result<Option<Spec>>;
async fn save_plan(&self, plan: &Plan) -> Result<()>;
async fn load_plan(&self, id: TaskId) -> Result<Option<Plan>>;
async fn save_qa_report(&self, report: &QaReport) -> Result<()>; // one per round
async fn load_qa_reports(&self, id: TaskId) -> Result<Vec<QaReport>>; // oldest first
async fn append_progress(&self, id: TaskId, note: &str) -> Result<()>;
async fn load_progress(&self, id: TaskId) -> Result<String>;
}
Contract. Saving is an upsert. A missing optional artefact is Ok(None), not an error;
a missing task is Error::storage. Writes must be durable when the call returns, because
resuming a run relies on what is on disk. The default file-backed implementation belongs to
vibe-pipeline (see Persistence layout). The trait is not stored in the
Registry; the host hands a SharedTaskStore to the pipeline directly.
Hook
#[async_trait]
pub trait Hook: Send + Sync {
fn name(&self) -> &str;
async fn before_phase(&self, _phase: Phase, _task: &Task) -> HookDecision { Continue }
async fn after_phase(&self, _phase: Phase, _task: &Task, _success: bool) {}
async fn before_tool(&self, _ctx: &ToolContext, _tool: &str, _input: &Value) -> HookDecision { Continue }
async fn after_tool(&self, _ctx: &ToolContext, _tool: &str, _output: &ToolOutput) {}
async fn augment_prompt(&self, _role: &AgentRole, _task: &Task) -> Option<String> { None }
}
Contract. Every method has a no-op default; override what you need. Hooks sit on the hot
path of every tool call, so keep them fast. An Abort(reason) from before_tool becomes an
error result the model reads, so write the reason as a sentence. augment_prompt output is
appended to the rendered system prompt, separated by a blank line.
Example.
#![allow(unused)]
fn main() {
use serde_json::Value;
use vibe_core::{AgentRole, Hook, HookDecision, Task, ToolContext};
struct NoPublish;
#[async_trait::async_trait]
impl Hook for NoPublish {
fn name(&self) -> &str { "no-publish" }
async fn before_tool(&self, _ctx: &ToolContext, tool: &str, input: &Value) -> HookDecision {
let cmd = input["command"].as_str().unwrap_or_default();
if tool == "bash" && cmd.contains("npm publish") {
HookDecision::Abort("publishing packages is reserved for humans".into())
} else {
HookDecision::Continue
}
}
async fn augment_prompt(&self, role: &AgentRole, _task: &Task) -> Option<String> {
(*role == AgentRole::Coder).then(|| "Never bump package versions.".to_string())
}
}
}
Registration. registry.add_hook(Arc::new(NoPublish)). Out-of-process plugins can only
provide before_tool (see Plugin protocol).
Plugin
pub trait Plugin: Send + Sync {
fn name(&self) -> &str;
fn version(&self) -> &str { "0.0.0" }
fn register(&self, registry: &mut Registry) -> Result<()>;
}
Contract. register is synchronous and may be called more than once (the host probes a
native plugin once into a scratch registry to list its contributions), so it must only add
entries, never perform side effects. An in-process plugin may contribute anything the
Registry holds: tools, providers, agents, workspaces, memories and hooks.
struct TeamRules;
impl Plugin for TeamRules {
fn name(&self) -> &str { "team-rules" }
fn register(&self, registry: &mut Registry) -> Result<()> {
registry.add_tool(Arc::new(WordCount)).add_hook(Arc::new(NoPublish));
Ok(())
}
}
let mut host = PluginHost::load(&configs).await?; // out-of-process plugins
host.add_native(Box::new(TeamRules));
host.register_all(&mut registry)?;
AgentSpec
Agents are added as data: registry.add_agent(spec) keyed by spec.role.name(). A spec
for a built-in role replaces the built-in agent; any other role adds a new one. Specs can come
from code (AgentSpec::new), from TOML files in .vibe/agents/
(vibe_agents::apply_agent_overrides) or from a plugin’s agents/list. See
Agent runtime and Customising agents.
EventBus and EventSink
#[async_trait]
pub trait EventSink: Send + Sync {
async fn on_event(&self, envelope: &Envelope); // must not block for long
}
EventBus::new(capacity) (default 1 024) wraps a tokio::sync::broadcast channel plus a list
of sinks. publish(event) stamps an Envelope, sends it to every live subscribe()
receiver (a lagging receiver loses old events, as with any broadcast channel) and then
awaits every sink in order. Sinks are therefore the right place for things that must see
every event, such as the events.jsonl writer; subscribers suit live UIs.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use vibe_core::{Envelope, EventBus, EventSink};
struct Stderr;
#[async_trait::async_trait]
impl EventSink for Stderr {
async fn on_event(&self, e: &Envelope) {
eprintln!("{} {:?}", e.at, e.event);
}
}
async fn wire(bus: &EventBus) { bus.add_sink(Arc::new(Stderr)).await; }
}
Plugin protocol
The Vibe Plugin Protocol (VPP) lets a program written in any language contribute tools,
agent specs and a before_tool hook to a run. It is JSON-RPC 2.0 over the plugin’s stdin and
stdout, shaped like the Model Context Protocol (MCP) so that an MCP stdio server works
unchanged as a tool plugin (ADR-005). The reference
implementation is crates/vibe-plugins: protocol.rs (wire types), client.rs (host side),
server.rs (plugin side), remote.rs (adapters), host.rs, manifest.rs and
discovery.rs.
Framing
- One JSON object per line, UTF-8, terminated by
\n(newline-delimited JSON). A message must not contain a raw newline. - The host writes requests and notifications to the plugin’s stdin and reads responses and notifications from its stdout.
- stderr is free-form diagnostics; the host logs each line at debug level as
[<plugin name>] <line>. Never write diagnostics to stdout. - Blank lines are ignored. An unparsable line from a plugin is logged and skipped.
Envelope
| Kind | Shape | Rule |
|---|---|---|
| Request | {"jsonrpc":"2.0","id":N,"method":"…","params":{…}} | has method and a non-null id; expects exactly one response |
| Notification | {"jsonrpc":"2.0","method":"…","params":{…}} | has method, no id; never answered |
| Response | {"jsonrpc":"2.0","id":N,"result":…} or {…,"error":{"code","message","data?"}} | exactly one of result and error |
id may be a number or a string; the host generates increasing integers starting at 1 and
only matches numeric ids. Responses may arrive in any order; the plugin may handle requests
concurrently. params is omitted when there are none (shutdown).
| Error code | Constant | Meaning |
|---|---|---|
| -32700 | PARSE_ERROR | invalid JSON received |
| -32600 | INVALID_REQUEST | not a valid request |
| -32601 | METHOD_NOT_FOUND | unknown method (mandatory answer for methods you do not implement) |
| -32602 | INVALID_PARAMS | bad parameters (also used by PluginServer for an unknown tool) |
| -32603 | INTERNAL_ERROR | internal failure |
An error response is reported by the host as
plugin `<name>` returned an error: <message> (code <code>).
Lifecycle
host plugin
│ spawn command in cwd, env, piped stdio │
│── initialize ───────────────────────────────▶│ 60 s
│◀──────────── {name, version, capabilities} ──│
│── notifications/initialized ────────────────▶│ (ignore it)
│── tools/list (if tools) ─────────────────▶│ 60 s, follows nextCursor
│── agents/list (if agents) ─────────────────▶│ 60 s
│ … run: tools/call (600 s), hooks/before_tool (60 s) …
│── shutdown ─────────────────────────────────▶│ 2 s
│ close stdin, wait 2 s for exit, then kill │
If the handshake or a listing fails, the process is shut down and the plugin counts as not
started. The host only calls tools/*, agents/list and hooks/before_tool when the
matching capability was advertised. All host writes go through one writer task that owns
the plugin’s stdin and writes whole queued lines one at a time, so a caller that times out or
is cancelled never leaves a partial line: its line is written whole, or skipped if its turn had
not come yet. A failed write marks the connection dead and fails every pending and later
call fast; after shutdown closed stdin, writes fail with plugin `<name>` is closed.
| Constant | Value | Applies to |
|---|---|---|
DEFAULT_TIMEOUT | 60 s | every request except tools/call |
TOOL_CALL_TIMEOUT | 600 s | tools/call |
SHUTDOWN_GRACE | 2 s | answer to shutdown, then again for process exit |
A timeout surfaces as plugin `<name>` did not answer `<method>` within <n>s.
Methods
initialize
The host sends the VPP fields and, for MCP servers, the MCP fields:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocol_version":"1",
"host":{"name":"vibe-factory","version":"0.5.1"},
"protocolVersion":"2025-06-18",
"clientInfo":{"name":"vibe-factory","version":"0.5.1"},
"capabilities":{}}}
{"jsonrpc":"2.0","id":1,"result":{"name":"echo","version":"0.1.0",
"capabilities":{"tools":true,"agents":true,"hooks":true}}}
protocol_version is PROTOCOL_VERSION ("1"), protocolVersion is
MCP_PROTOCOL_VERSION ("2025-06-18"). Each capability flag is a boolean; when reading, an
object ({"tools": {}}) counts as true and null or absence as false. If name or
version is absent, the MCP serverInfo object is used.
tools/list
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"echo","description":"Return the given text unchanged.",
"input_schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]},
"mutating":false}]}}
| Descriptor field | Notes |
|---|---|
name | required; registered under this name, overriding a same-named tool |
description | default "" |
input_schema | inputSchema accepted; default {"type":"object"} |
mutating | optional; when absent, !annotations.readOnlyHint; when that is absent too, true |
If the result carries nextCursor, the host requests {"cursor": "<value>"} again, up to
100 pages.
tools/call
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"echo","arguments":{"text":"hi"},
"_meta":{"vibe":{"workspace_root":"/repo/.vibe/worktrees/add-oauth-6f1c3e0a",
"task_id":"6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19","agent":"coder",
"permissions":{"read":true,"write":true,"execute":true,"network":false}}}}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"hi"}],"is_error":false}}
_meta.vibe is the ToolCallContext built from the agent’s ToolContext: the workspace
root the tool must stay inside, the task id (omitted when there is none), the calling
agent’s role name and its permission flags. A plugin tool that touches files must resolve
paths against workspace_root and refuse to leave it; the host cannot enforce containment
inside another process. MCP servers ignore _meta.
The result’s text blocks are joined with newlines; any non-text block becomes
[<type> content omitted]. is_error (or isError) turns the result into an error the
model sees. A tool failure should be a result with is_error: true, not a JSON-RPC error;
PluginServer does this for handlers that return Err.
agents/list
{"jsonrpc":"2.0","id":4,"method":"agents/list","params":{}}
{"jsonrpc":"2.0","id":4,"result":{"agents":[
{"role":"echo_agent","system_prompt":"You repeat what you are told using the `echo` tool.",
"description":"Echoes its instructions","tools":{"named":["echo"]},"thinking":"low","max_steps":20}]}}
Each entry is the JSON form of AgentSpec. Only role and system_prompt are required;
tools, model, thinking, max_steps, structured_output and max_tokens take the
AgentSpec defaults. A built-in role name replaces the built-in agent.
hooks/before_tool
{"jsonrpc":"2.0","id":5,"method":"hooks/before_tool","params":{"tool":"forbidden","input":{}}}
{"jsonrpc":"2.0","id":5,"result":{"decision":"abort","reason":"the echo plugin forbids this tool"}}
decision is continue (default) or abort; reason defaults to "vetoed by plugin".
The hook sees every tool call of every agent. Remote hooks fail closed: if the plugin
times out, has exited or answers with an error, the call is aborted with hook plugin unavailable: …. before_phase, after_phase, after_tool and augment_prompt have no
protocol method; they are available to in-process plugins only.
shutdown and ping
shutdown has no params and is answered with {}; the plugin then exits. An MCP server
that does not know shutdown is tolerated: the host closes stdin and kills the process
after the grace period. ping is answered with {} by either side; it is the only request a
plugin may send to the host (any other gets -32601).
Notifications
| Direction | Method | Params |
|---|---|---|
| host → plugin | notifications/initialized | none; sent once after initialize, may be ignored |
| plugin → host | log | {"level": "error"|"warn"|"info"|"debug"|"trace", "message": "…"} |
| plugin → host | notifications/message (MCP) | {"level": "…", "data": …}; data is used when message is absent |
Log levels map onto tracing levels (warning is accepted, critical, alert and
emergency map to error, anything else to info).
MCP compatibility
| MCP field | Accepted as |
|---|---|
serverInfo | fallback for name and version |
capability objects {"tools": {}} | true |
inputSchema | input_schema |
annotations.readOnlyHint | inverse of mutating |
nextCursor | pagination of tools/list |
isError | is_error |
notifications/message | log |
MCP servers expose neither agents nor VPP hooks, so only their tools are registered.
Manifests and declarations
A plugin is declared inline in .vibe/config.toml ([[plugins]], see
Plugins) or by a manifest vibe-plugin.toml:
name = "echo" # required, unique
version = "0.1.0"
description = "Echoes its input"
command = ["./bin/vibe-echo-plugin"] # required; program then arguments
required = false # true: a failure to start aborts loading
capabilities = ["tools", "agents", "hooks"] # or [capabilities] tools = true …; informational
[env]
ECHO_PREFIX = ">"
The process runs in the manifest’s directory. A program written as a relative path
(containing a separator, such as ./bin/p) is resolved against that directory; a bare name
(python3) is looked up on PATH. Declared capabilities are informational: the
initialize answer is authoritative.
Discovery precedence
- Inline
[[plugins]]entries of.vibe/config.toml. <project>/.vibe/plugins/*/vibe-plugin.toml, sorted by directory name.<user config dir>/vibe/plugins/*/vibe-plugin.toml(dirs::config_dir(), for example~/.config/vibe/pluginson Linux,~/Library/Application Support/vibe/pluginson macOS).
The first declaration of a name wins; later ones are skipped with a debug log. Invalid
manifests are logged and skipped. discovery::plugin_configs(project, &config) returns the
merged list.
Host failure policy
PluginHost::load starts every plugin concurrently.
| Situation | Result |
|---|---|
| optional plugin fails to spawn or handshake | warning skipping plugin …, run continues |
required = true plugin fails | every started plugin is shut down; ErrorKind::Plugin error required plugin `x` failed to start: … |
| same name declared twice | the duplicate is ignored with a warning |
| tool call times out or errors | error result to the model; the run continues |
| hook plugin unavailable | the guarded tool call is aborted (fail closed) |
Writing a plugin in Rust
Add vibe-plugins, vibe-core, tokio and serde_json as dependencies and build a
PluginServer:
use serde_json::{Value, json};
use vibe_core::{AgentRole, AgentSpec, HookDecision, ToolOutput, ToolSelection};
use vibe_plugins::{PluginServer, ToolCallContext};
#[tokio::main]
async fn main() {
let server = PluginServer::new("todo-scan", "0.1.0");
let log = server.logger();
let server = server
.tool_with_context(
"count_todos",
"Count TODO markers in a file of the workspace.",
json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
move |args: Value, ctx: Option<ToolCallContext>| {
let log = log.clone();
async move {
let root = ctx.map(|c| c.workspace_root).unwrap_or_else(|| ".".into());
let path = root.join(args["path"].as_str().unwrap_or_default());
let text = tokio::fs::read_to_string(&path).await?;
log.log("debug", format!("scanned {}", path.display()));
Ok(ToolOutput::ok(text.matches("TODO").count().to_string()))
}
},
)
.agent(
AgentSpec::new(AgentRole::Custom("todo_auditor".into()), "List the TODOs of {{task_title}}.")
.with_tools(ToolSelection::Named(vec!["count_todos".into()])),
)
.before_tool(|tool, _input| async move {
if tool == "bash" { HookDecision::Abort("todo-scan forbids the shell".into()) } else { HookDecision::Continue }
});
let code = if server.run_stdio().await.is_ok() { 0 } else { 1 };
std::process::exit(code);
}
The example joins paths without a containment check for brevity; a real tool must reject
.. and absolute paths. Notes:
tool/tool_with/tool_with_contextregister a handler; anErrbecomes a result withis_error: true. Usetool_with(ToolDescriptor { mutating: Some(false), .. })to let the host run the tool concurrently.- Capabilities are derived from what you registered.
- Requests run concurrently;
shutdownor end of input stops the server. - Call
std::process::exitafterrun_stdioreturns: Tokio reads stdin on a blocking thread that would otherwise keep the runtime alive.vibe-echo-plugindoes this. PluginServer::serve(reader, writer)andPluginServer::handle(method, params)let you test the plugin without a process.
Build it, then add .vibe/plugins/todo-scan/vibe-plugin.toml with
command = ["/path/to/target/release/todo-scan"].
Writing a plugin in Python
Any language that can read lines and write JSON works. This sketch implements the contract with one read-only tool and a hook:
#!/usr/bin/env python3
import json, sys
TOOLS = [{"name": "upper", "description": "Uppercase a text.", "mutating": False,
"input_schema": {"type": "object", "properties": {"text": {"type": "string"}},
"required": ["text"]}}]
def handle(method, params):
if method == "initialize":
return {"name": "upper", "version": "0.1.0",
"capabilities": {"tools": True, "agents": False, "hooks": True}}
if method == "tools/list":
return {"tools": TOOLS}
if method == "tools/call":
text = params.get("arguments", {}).get("text", "")
return {"content": [{"type": "text", "text": text.upper()}], "is_error": False}
if method == "hooks/before_tool":
if params.get("tool") == "bash" and "rm -rf" in json.dumps(params.get("input")):
return {"decision": "abort", "reason": "no recursive deletes"}
return {"decision": "continue"}
if method in ("ping", "shutdown"):
return {}
raise KeyError(method)
for line in sys.stdin:
if not line.strip():
continue
msg = json.loads(line)
if "id" not in msg or "method" not in msg:
continue # notification or stray response
try:
reply = {"jsonrpc": "2.0", "id": msg["id"], "result": handle(msg["method"], msg.get("params") or {})}
except KeyError:
reply = {"jsonrpc": "2.0", "id": msg["id"],
"error": {"code": -32601, "message": "method not found: " + msg["method"]}}
print(json.dumps(reply), flush=True)
if msg["method"] == "shutdown":
break
Declare it with command = ["python3", "upper.py"] in its manifest directory.
Security model
Vibe Factory lets language models edit files and run commands in a developer’s repository. The security model answers one question: what is the worst a confused or manipulated model can do, and how do we bound it? It is built in layers; each layer is independent and any one of them can be tightened without touching the others.
┌───────────────────────────────────────────────────────────┐
│ 5. Isolation git worktree (default) / plugin sandbox │
│ 4. Hooks before_tool veto, audit, prompt rules │
│ 3. Shell policy parser + blocked programs + validators │
│ 2. Permissions read / write / execute / network │
│ 1. Path containment every path inside the workspace │
└───────────────────────────────────────────────────────────┘
1. Path containment (vibe-core)
ToolContext::resolve_path is the single entry point for paths. It joins relative paths
to the workspace root, normalises . and .. lexically, canonicalises the longest
existing prefix (resolving symbolic links), re-appends the non-existing suffix and checks
that the result starts with the canonical workspace root (or one of
Permissions::extra_read_paths).
Two subtleties are handled explicitly:
- a dangling symbolic link cannot be canonicalised; the path is denied, because its eventual target is unknown and a write would create a file at that target;
- the writing tools (
write_file,edit_file, thebashworking directory) walk every component withsymlink_metadataand refuse any symbolic link, even one that points inside the workspace, so that a later retarget cannot redirect a write.
extra_read_paths are honoured for reading only; the writing tools require the canonical
path to be inside the workspace root proper.
2. Permissions (vibe-core)
Permissions { read, write, execute, network, extra_read_paths } travel with every
ToolContext. The built-in tools check them before doing anything. Presets:
read_only(), local() (default: everything but network) and all(). The pipeline
derives them from SecurityConfig and from the agent’s ToolSelection: a read-only agent
never receives a writing tool in the first place, and even if it did, the permission check
would refuse.
3. Shell policy (vibe-tools)
The bash tool hands the command line to SecurityPolicy::validate_with_network before
spawning anything. The policy is a denylist with per-command validators, optionally
narrowed by an allowlist (ADR-004).
Parsing
command_parser::parse_command splits on |, ||, &&, ;, & and newlines, descends
into $(…), backticks and ( … ), strips env/time/nice/nohup/xargs/timeout/
exec/command/builtin wrappers and VAR=value prefixes, and returns one
CommandSegment { program, argv } per command, up to MAX_NESTING_DEPTH (16). It fails
closed on case/function constructs, env -S, here-documents containing substitutions,
redirects without target and unbalanced quotes or parentheses. Program names that would be
computed at run time ($, backtick, *, ?, [, {, }, ^, %) are denied.
Rules, in order
BLOCKED_PROGRAMSandSecurityConfig::blocked_commands→ deny.SecurityConfig::allowed_commandsnon-empty and program not in it (nor inALWAYS_ALLOWED, the harmless builtins) → deny.NETWORK_PROGRAMSand network not permitted → deny.- Per-command validators:
rm,chmod,git,kill,pkill/killall, the shells (-cvalidated recursively; stdin-fed shells denied),eval,find -exec, database clients,dropdb/dropuser. The exact rules are listed in the user guide.
Every denial is a sentence returned to the model as an error tool result
(ToolOutput::error, metadata.denied = true), never a framework error, so the agent can
adapt instead of crashing the run.
Execution
Commands run through sh -c (Unix) or cmd /C (Windows) in the workspace, with stdin
closed, GIT_TERMINAL_PROMPT=0 and pagers disabled. A timeout kills the whole process
group (taskkill /T /F on Windows). Output is merged, capped at 30 000 characters
keeping head and tail, and ends with the exit code.
4. Hooks (vibe-core, plugins)
Hook::before_tool runs before every tool call with the ToolContext, tool name and
input; an Abort(reason) becomes an error tool result. after_tool sees every output.
augment_prompt injects project rules into the system prompt. Remote hooks (plugins)
fail closed: if the plugin cannot answer, the call is aborted. This is where teams put
policies the static layer cannot express.
5. Isolation (vibe-workspace, plugins)
The default workspace is a git worktree on its own branch (ADR-003); the project checkout is never modified until the merge step, which refuses to run on a dirty tree. With the git worktree workspace each subtask attempt also gets its own worktree, integrated into the task branch one at a time.
The container workspace (vibe_workspace::ContainerWorkspace) keeps the worktrees and
changes where commands run. vibe_core::CommandRunner is the seam: the bash tool checks
the policy and the paths as usual, then asks the runner for the process to spawn
(PreparedCommand: program, arguments, a cleanup command and the exit status that means
“the runner failed”). ContainerRunner returns docker run --rm (or podman run --rm)
with fixed hardening flags (--cap-drop=ALL, --security-opt=no-new-privileges,
--read-only, a /tmp tmpfs, --pids-limit), the worktree bind-mounted at /workspace,
its .git pointer mounted read-only over itself so a command cannot redirect the host’s git
to a crafted directory, and --network=none unless a network is both configured and
granted. The cleanup (rm --force <name>) runs when the command ends, times out or its
future is dropped, which kills everything the command started. Settings are validated
before any container starts (ContainerSettings::from_config): namespace-sharing networks,
sockets, writable mounts over .git or .vibe and reserved targets are refused, and there
is no raw flag passthrough. File tools are unaffected: they act on the host worktree under
path containment. VMs and remote sandboxes remain WorkspaceProvider plugins.
A worktree shares .git/config with the user’s checkout, so the provider treats the
repository configuration as untrusted after agents have run:
- every framework git command passes
NEUTRAL_CONFIG(core.hooksPathpointing at an empty directory,core.fsmonitor=false,core.pager=cat,core.editor=true,core.sshCommand=ssh) andGIT_CONFIG_NOSYSTEM=1, so no hook or helper runs during a checkpoint commit, a merge or a checkout; config_guard::ConfigSnapshotcapturesgit config --local --listatopen;mergediffs the current configuration against it and fails with the list of changed keys unless only volatile keys changed (branch.vibe/*,core.repositoryformatversion). A missing snapshot fails closed.accept_config_changesre-baselines after a human review.
The writing tools and the shell policy additionally refuse any path inside a .git
directory, so hook scripts cannot be planted without going through the configuration.
Secrets
Provider layers scrub sk-…, Bearer …, token=, api_key= and access_token= from
every logged message (vibe_providers::scrub_secrets). API keys are read from environment
variables by default (api_key_env); inline keys in config.toml are supported but
discouraged. Plugins receive only the environment you declare in their configuration.
Known limits
- The shell policy is defence in depth, not a sandbox: interpreters (
python -c,node -e,perl -e) and build tools that run arbitrary scripts are allowed. - On Windows the command is parsed with POSIX rules before being handed to
cmd /C;cmd-specific tricks beyond^and%escapes may not be recognised. - Prompt injection through repository content (a README telling the agent to run something) is mitigated by the same layers, not by detection.
Report weaknesses privately to the maintainers before opening a public issue.
Persistence layout
Vibe Factory keeps all of its state in plain files under <project>/.vibe/. There is no
database and no daemon: the artefacts on disk are the state machine. Each phase reads the
files of the previous ones and writes its own, which makes a run inspectable with cat and
jq, and resumable after a crash. This page lists every file, its shape, how it is written
and which ones you may edit.
The .vibe/ directory
<project>/.vibe/
├── config.toml project configuration
├── agents/*.toml agent overrides and custom agents (+ prompt files)
├── plugins/<name>/vibe-plugin.toml plugin manifests
├── memory.jsonl project memory: one lesson per line, shared by tasks
├── server.token token of a running `vibe serve` (owner-only)
├── tool-output/<task dir>/<run>/<call>.txt complete output of every tool call (trace store)
├── worktrees/ one git worktree per task (git_worktree workspace)
│ ├── .gitignore "*"
│ └── <slug>-<id8>/ the task's workspace
│ └── .vibe/
│ ├── specs/<id8>-spec.md markdown copy written by the spec writer
│ └── tool-output/<uuid>.txt full text of truncated tool outputs
└── tasks/
├── index.json task id → directory and number
├── .index.lock OS lock serialising index updates across processes
└── NNN-<slug>/ one directory per task
├── task.json
├── spec.json spec.md
├── plan.json plan.md
├── qa_report_1.json qa_report_1.md one pair per QA round
├── progress.md append-only, timestamped
├── memory/gotchas.md memory/patterns.md
├── events.jsonl every event of every run
├── run.json state of the last run
├── run.lock run.owner OS lock and pid of the process running it
└── cancel.request present while a `vibe cancel` is pending
| Path | Written by | Read by |
|---|---|---|
config.toml | vibe init, vibe config set, you | every command (Configuration) |
agents/ | you, vibe agents export | agent loading (Customising agents) |
plugins/ | you, plugin installers | plugin discovery (Plugins) |
worktrees/ | the git_worktree workspace provider | agents, you (Workspaces) |
tasks/ | FileTaskStore (vibe-pipeline) | the pipeline, vibe task, vibe status |
The tool-output/<uuid>.txt files and the specs/ directory live in the workspace, so with
the in_place workspace they appear directly under <project>/.vibe/. The trace store
<project>/.vibe/tool-output/<task dir>/<run>/ is always at the project root: one file per
tool call, named by the call id of its tool_called/tool_returned events and capped at
pipeline.trace_max_chars characters (see Events).
pipeline.trace_outputs = false turns it off; vibe task discard removes the task’s
directory. Workspace change summaries and the
merge checkpoint commit both exclude .vibe/.
The task store
FileTaskStore::open(project_root) creates .vibe/tasks/ if needed. A task gets its
directory the first time it is saved: the next sequence number, zero-padded to three digits,
and the slug of its title (lowercase ASCII alphanumerics, other runs replaced by -, at most
48 characters, task if empty). The number and the directory never change afterwards, even
if the title does.
index.json
{
"next_number": 2,
"tasks": {
"6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19": { "dir": "001-add-oauth-login-github", "number": 1 },
"a4b21f77-0c9e-4d3b-b8f1-2e6a9c0d4e58": { "dir": "002-fix-typo-in-readme", "number": 2 }
}
}
A new number is max(next_number, highest number in use) + 1, so deleting the last task
does not reuse its number. User references are resolved by find_by_prefix: a number of up
to six digits (3, 003), an exact directory name (003-add-login), or a prefix of the
task id; an ambiguous prefix is an error. A corrupt index is reported as
corrupt task index <path>: … rather than silently rebuilt.
Files of a task
| File | Content | Written |
|---|---|---|
task.json | the Task | on creation and at every status change |
spec.json, spec.md | the Spec, and its rendering | end of the spec phase |
plan.json, plan.md | the Plan with subtask statuses, attempts and notes | end of plan, then after every subtask state change |
qa_report_<n>.json, .md | the QaReport of round n | after each QA round |
progress.md | timestamped notes from every phase | appended throughout |
memory/patterns.md | spec findings | appended at the end of spec |
memory/gotchas.md | repeatedly failing subtasks, escalated QA issues | appended when they happen |
events.jsonl | one Envelope per line | appended by the run’s event sink |
run.json | the RunState of the last run | before and after every phase, and at the end |
run.lock, run.owner | empty lock file, pid | taken by the process running the task, released when it ends or dies |
cancel.request | marker | written by vibe cancel, consumed by the running process |
The shapes of Task, Spec, Plan and QaReport are documented in
Domain model. A task.json:
{
"id": "6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19",
"title": "Add OAuth login (GitHub)",
"description": "Users should be able to sign in with their GitHub account.",
"status": "ready",
"complexity": "standard",
"labels": ["auth"],
"source": { "kind": "manual" },
"created_at": "2026-09-27T08:12:03.114Z",
"updated_at": "2026-09-27T08:41:57.902Z",
"branch": "vibe/add-oauth-login-github-6f1c3e0a"
}
branch is recorded when a run opens a workspace that has one (absent with in_place);
vibe pr, vibe task show and vibe task discard use it instead of recomputing the branch
from the title.
plan.md marks subtasks with [ ] pending, [~] in progress, [x] done, [!] failed and
[-] skipped, and shows dependencies by title, the attempt count, verification steps and
notes. Markdown files are always rendered from the JSON and never parsed back.
run.json
{
"run_id": "a3f09c2e-5d71-4b8e-9f06-1c2d3e4f5a6b",
"task_id": "6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19",
"current_phase": "qa",
"completed_phases": ["assess", "spec", "plan", "build", "qa", "fix", "qa", "fix"],
"qa_round": 3,
"profile": {
"phases": ["spec", "plan", "build", "qa", "fix", "merge"],
"research": false,
"critique": false,
"full_spec": true
},
"started_at": "2026-09-27T08:12:10.520Z",
"updated_at": "2026-09-27T08:39:44.061Z",
"status": "paused",
"last_error": "issue `login button has no accessible label` was reported in 3 consecutive QA rounds; escalating to a human"
}
| Field | Meaning |
|---|---|
run_id | identifier of the run, kept across resumes |
current_phase | the phase executing, or the next one to execute when the run is not running |
completed_phases | phases that completed, in order; qa and fix may repeat |
qa_round | QA reviews performed so far; numbers the report files |
profile | the profile chosen by the assessment (absent until known) |
status | running, paused, finished, failed or cancelled |
last_error | why the run stopped, for any status other than finished |
Only the last run of a task is kept: a new vibe run overwrites run.json. Its events
remain in events.jsonl, which is never truncated and can be split by event.run:
jq -c 'select(.event.type == "phase_finished") | .event' .vibe/tasks/001-*/events.jsonl
Only events carrying the run’s id are written; log events without a run id are not.
Writing strategy
Every JSON and markdown artefact is written atomically: the content goes to a uniquely
named temporary file in the same directory (.<name>.<uuid>.tmp), which is then renamed over
the target. A crash therefore leaves either the old or the new file, never a truncated one;
at worst a stray .tmp file, safe to delete. JSON is pretty-printed with a trailing newline.
Appends (progress.md, memory files) are serialised by locks inside one process;
events.jsonl has its own lock per sink. Across processes, an OS lock on run.lock lets
one process at a time run a given task (a second vibe run fails with already being run by another process), and .index.lock serialises updates of index.json, so a terminal, a
terminal UI and vibe serve can work on the same project. Readers never hold a lock: the
read layer below only consumes complete lines of the logs, and the history only probes a
task’s run lock for an instant to tell a running run from an interrupted one.
Missing files are normal: a task without spec.json simply has no spec yet, and loading it
returns None. A file that exists but does not parse is an error naming the path, so a bad
hand edit is reported instead of ignored.
What you may edit
| File | Safe to edit? | Notes |
|---|---|---|
config.toml, agents/*, plugins/* | yes | read at the start of each command |
progress.md | yes | agents read its last 8 000 characters; add guidance for the next run |
memory/gotchas.md, memory/patterns.md | yes | fed to every agent except the assessor (6 000 characters) |
task.json | with care | editing description changes what the next run sees; set complexity to null to have it re-assessed |
spec.json, plan.json | with care | must stay valid against the types; edit the JSON, not the .md, which is regenerated; a plan must still pass validation |
qa_report_<n>.json | no | the escalation check reads the titles of the last three rounds |
run.json | no | delete it to forget the last run instead |
index.json, events.jsonl | no | use vibe task discard to remove a task |
Stop any run of the task before editing its files.
What to commit
In a git repository, vibe init creates (or completes, without duplicating lines)
.vibe/.gitignore:
# tasks/ is committed on purpose: specs, plans and QA reports are project history.
worktrees/
tool-output/
Task directories are therefore committed by default: reviewers see the spec, plan and QA reports next to the change they produced.
| Commit | Ignore |
|---|---|
config.toml (without inline api_key values) | worktrees/ (ignored by vibe init) |
agents/ | tool-output/ (ignored by vibe init: the trace store, plus truncated outputs with in_place) |
plugins/ manifests you want everyone to load | specs/ (only appears with in_place) |
tasks/ (the default) | optionally tasks/*/events.jsonl and tasks/*/run.json |
The event log is large and machine-oriented, and run.json changes on every run; add them
to .vibe/.gitignore if the noise bothers you. To keep all task data local, add tasks/.
.vibe/worktrees/ also contains its own .gitignore with *, written when the first
worktree is created, so worktrees never show up in your git status even without these
entries.
Reading the store: the read layer
Interfaces never parse these files themselves; vibe-pipeline gives them a read layer on
the same files (ADR-007,
ADR-008). It stores nothing of its own:
histories and traces are rebuilt from the events and git each time they are asked for, and
followers only remember how far they have read.
| API | Reads | For |
|---|---|---|
EventReader (events_log) | one events.jsonl, from a byte offset | following a task without re-reading its whole log |
FileTaskStore::all_events(after), AllEventsFollower | every task’s events.jsonl, tagged with the task id and number | one stream for the whole project, ordered by time |
history::project_history, history::task_history | task.json, events.jsonl, run.json, qa_report_*.json, git | what finished tasks did, cost and changed |
trace::run_trace, trace::read_output | events.jsonl, .vibe/tool-output/ | the tool calls of a run with their complete outputs |
Reading a log while it grows. A writer appends whole lines, but a reader can see one
half written: EventReader keeps an incomplete last line until its end arrives, and
starts over when the file shrinks or is replaced. Complete lines that do not parse are
skipped, as when a log is loaded whole.
All tasks at once. Each event is tagged {task, number} and serialised flat, the
envelope’s fields next to them: {"task":"…","number":3,"schema":2,"seq":17,"at":"…","event":{…}}.
Events are ordered by at, then task number, then seq: that triple is the event’s
EventCursor, written <nanoseconds>-<number>-<seq> so it can serve as an SSE id. A client
resumes after the cursor of the last event it saw, which does not lose events of other tasks
published at the same instant. AllEventsFollower re-reads index.json at each poll, so
tasks created later are followed too; a log that cannot be read is reported with the poll’s
result and retried at the next poll, without losing the events of the other logs. vibe serve
runs a single follower for all its open event streams, whatever their number.
Runs. A run and its resumes share one id: a run is summarised from its first
run_started to its last run_finished, whose totals already cover the resumes. A
run_finished logged before 0.5 has no totals; run.json supplies them for the last run,
and older runs report their totals as unknown rather than as zeros. run.json also
supplies the totals of a resumed run that has not finished again. A run without a
run_finished since its last start is running when a process holds the task’s run lock,
interrupted otherwise.
Changed files, from the most to the least exact source, one or two git commands per task after three or four for the whole project:
- a
mergedevent. A true merge commit is compared with its first parent: exact. A fast-forward (one parent, or a merged commit that is one of the task’s own commits, such as the merge commit of a subtask integration at the tip of the task branch) is compared from the parent of the task’s oldest recorded commit that the merged commit contains. That is exact when the recorded commits reach back to where the task branch forked, and it has limits: commits discarded by a reset and a relaunch are skipped, so the diff starts at the relaunched work; commits git makes without an event (the checkpoint before merging) are covered only if an older recorded commit precedes them; and when no recorded commit is contained, only the merged commit itself is compared with its parent. That last case setsapproximate. A merged branch that still exists diffs as empty against its base, so the merge goes first; - the task branch still exists:
git diff --name-status <base>...<branch>, with the base resolved asvibe prdoes (branch.<b>.vibebase, thenbase_branch, then the default branch); an empty diff is skipped; - the
filesof thecommittedevents (statuses unknown); - the paths written by
write_fileandedit_filecalls (approximate: files written by commands are missing); - nothing.
approximate means the list may miss files or list files the task did not change in the
end; it is set for sources 3 and 4 and for a fast-forward without a contained commit.
Revisions read from the log or the configuration are passed to git after
--end-of-options, so a value that starts with - is never read as an option.
A git error on one task, or a task file that does not parse (run.json, a QA report), is
recorded on that task; only an unreadable index fails the whole history. Git runs with the same neutralised configuration as every other
framework command (no hooks, no external diff).
Tool calls pair by call id; calls logged before 0.5 pair in order per role, tool and
subtask, and are marked as such. A trace file is only read under .vibe/tool-output/: a
recorded path that is absolute or climbs out with .. is ignored, and read_output
resolves symbolic links and refuses a file that resolves outside the trace store.
How a resume reads the state
Pipeline::resume needs only the task directory:
task.jsongives the task and its stored complexity;run.jsongives the run id, the profile, the phase to restart at, and whether the run is resumable (anything butfinished);spec.jsonandplan.jsonare loaded if present, so spec and plan are not redone;- in
plan.json,donesubtasks are kept,in_progressones go back to pending, and after a failed buildfailedandskippedones get a fresh attempt budget; qa_report_*.jsonprovide the earlier rounds to QA and to the escalation check;progress.mdandmemory/feed the agents’ context as in any run.
The workspace is reopened by the workspace provider (the same worktree and branch), so the commits made by earlier attempts are still there. See The pipeline for the exact rules.
Architecture decision records
Significant decisions are recorded here in the lightweight ADR format: context, decision, consequences. A new ADR is added when a decision changes the shape of the framework; an existing ADR is never edited after acceptance, it is superseded by a new one. The one exception is a cross reference: a link to the later ADR that details or supersedes a point may be added to an accepted ADR, its text does not change otherwise.
| ADR | Title | Status |
|---|---|---|
| 001 | Rust and a cargo workspace | Accepted |
| 002 | Traits as extension points | Accepted |
| 003 | Git worktrees for isolation | Accepted |
| 004 | Denylist shell policy with validators | Accepted |
| 005 | JSON-RPC over stdio plugin protocol | Accepted |
| 006 | Structured QA verdicts | Accepted |
| 007 | One seam for every user interface | Accepted |
| 008 | Trace store and read layer | Accepted |
ADR-001: Rust and a cargo workspace
Status: Accepted — 2026-09-26
Context
An autonomous coding framework runs for hours, spawns processes, talks to several network services concurrently and manipulates the user’s repository. It must be fast to start, cheap to run on a laptop, safe against memory errors, and distributable as a single binary on Linux, macOS and Windows. It must also be pleasant to extend by people who are not its authors.
Decision
The framework is written in Rust (edition 2024, MSRV 1.88) and organised as a cargo
workspace of small, layered crates. Each crate has one responsibility and a documented
public API; vibe-core holds only types and traits and has no I/O dependencies.
unsafe_code is forbidden workspace-wide, every public item is documented
(missing_docs = warn with -D warnings in CI), and clippy runs on all targets.
Consequences
- Single static binary, no runtime to install, fast start-up.
- Strong typing of every artefact (spec, plan, report) catches malformed model output at the boundary instead of deep inside the pipeline.
- Async everywhere via Tokio; agents and tools run concurrently without threads per task.
- Higher barrier for casual contributors than a scripting language; mitigated by the out-of-process plugin protocol (ADR-005) that lets extensions be written in any language.
ADR-002: Traits as extension points
Status: Accepted — 2026-09-26
Context
The framework must be “very open”: teams will want other model vendors, other tools, other isolation strategies (containers, remote sandboxes), other memory back-ends, and their own agents. Hard-coding any of these makes the framework a product rather than a platform.
Decision
Every replaceable concern is a small trait in vibe-core:
| Trait | Default implementation |
|---|---|
ModelProvider | Anthropic, OpenAI-compatible, mock |
Tool | read/write/edit/list/glob/grep/bash |
WorkspaceProvider | git worktree, in place |
MemoryStore | in-memory, file-backed |
TaskStore | file-backed under .vibe/tasks |
Hook | none (hooks are opt-in) |
Plugin | native and out-of-process adapters |
Agents are not a trait but data (AgentSpec): a role, prompt, tool selection, model
selection and thinking level. One runtime executes every spec.
Implementations are collected in a Registry at start-up. The default pipeline only ever
talks to the registry, never to concrete types.
Consequences
- Anything the built-in pipeline can do, a plugin can do.
- Tests use scripted providers and fake tools without network or filesystem.
- Trait objects (
Arc<dyn …>) everywhere; a small runtime cost, irrelevant next to model latency. - Trait evolution must be backwards compatible: new methods get default bodies.
ADR-003: Git worktrees for isolation
Status: Accepted — 2026-09-26
Context
Agents edit files and run commands. If they work in the user’s checkout, a bad run leaves a broken tree, and two tasks cannot run at once. Containers give stronger isolation but require a daemon, images, and volume juggling that many developers do not want.
Decision
The default WorkspaceProvider creates a git worktree per task under
.vibe/worktrees/<slug>-<id> on a branch vibe/<slug>-<id> from the configured base
branch. Agents are confined to that directory by path containment. Integration tries a
fast-forward, then a regular merge, and reports conflicts for a human (optionally after an
AI-assisted resolution pass) instead of guessing.
in_place mode exists for experiments and for repositories that are not git.
Isolation is a trait, so containers or remote sandboxes can be added as plugins without touching the pipeline.
Consequences
- Several tasks can run in parallel on the same repository.
- The main branch is never touched until the user (or
auto_merge) says so. - Worktrees consume disk space;
vibe task discardremoves them. - Non-git projects lose isolation unless a plugin provides it.
ADR-004: Denylist shell policy with validators
Status: Accepted — 2026-09-26
Context
Agents need a shell to build, test and inspect a project. A pure allowlist breaks on every
new tool chain; a wide-open shell is dangerous. Real incidents come from a small set of
commands: destructive rm, privilege escalation, disk and system management, identity
tampering in git, and destructive database statements.
Decision
Every command line is parsed into segments (pipes, &&, ;, subshells, bash -c) and
every segment is checked against:
- a built-in list of blocked programs (system, disk, privilege, network administration);
- per-command validators for
rm,chmod,git,kill,pkill, database clients anddropdb/dropuser; - the project configuration: extra blocked programs, an optional allowlist, and whether
network clients (
curl,wget) are permitted.
Unparsable input is denied. Every denial is a full sentence returned to the model so it can choose another approach. Commands always run inside the workspace with a timeout.
Consequences
- Works out of the box on any stack.
- Not a sandbox: a determined model could still find a harmful command. Containers or VM workspaces remain the answer for untrusted code; the policy is defence in depth.
- Teams with stricter needs switch on the allowlist.
ADR-005: JSON-RPC over stdio plugin protocol
Status: Accepted — 2026-09-26
Context
Rust traits are the native extension mechanism, but requiring Rust for every extension
contradicts the goal of openness. Meanwhile the Model Context Protocol (MCP) has made
“JSON-RPC 2.0 over stdio, tools/list, tools/call” a de facto standard with hundreds of
existing servers.
Decision
Out-of-process plugins speak the Vibe Plugin Protocol: newline-delimited JSON-RPC 2.0
over stdin/stdout. The method names and payload shapes for tools follow MCP so that an MCP
stdio server works unchanged as a tool plugin. Additional methods (agents/list,
hooks/before_tool) expose the other extension points.
Plugins are declared in .vibe/config.toml or discovered from vibe-plugin.toml
manifests in .vibe/plugins/ and the user configuration directory. A PluginServer
helper makes writing a plugin in Rust a few lines; any language can implement the wire
contract directly.
Consequences
- Immediate access to the MCP ecosystem for tools.
- Plugins crash in their own process; a non-required plugin failure never stops a run.
- Latency per call (process boundary, JSON); acceptable next to model latency.
- Native in-process plugins remain available for performance-sensitive extensions.
ADR-006: Structured QA verdicts
Status: Accepted — 2026-09-26
Context
A review loop needs an unambiguous verdict to decide whether to fix again, stop, or ask a human. Parsing free text (“Status: passed”, “LGTM”, “approved with nits”) is brittle, and having two sources of truth (a markdown report and a JSON status) invites disagreement.
Decision
The QA reviewer ends its session with a JSON document that deserialises into
vibe_core::QaReport (verdict, summary, issues[] with severity, requirement, file,
line and suggested fix). That document is the only source of truth. The markdown report
is rendered from it for humans, never parsed. Invalid JSON goes through a repair call and
one retry; a still-invalid answer counts as Inconclusive and consumes a round.
The same rule applies to every structured artefact: complexity assessment, spec, plan.
Consequences
- The pipeline can reason about severities (block on
High+, tolerateLow). - Escalation is deterministic: the same issue title three rounds in a row stops the loop.
- Prompts must be explicit about the schema; the runtime helps with extraction and repair.
ADR-007: One seam for every user interface
Status: Accepted — 2026-09-27
Context
Up to 0.2 the only interface is the vibe command line: vibe run builds a pipeline in
its own process, prints events as they come, and the run ends with the process. A terminal
UI, a local web UI and an editor extension are all wanted. Each needs the same things:
start, resume and cancel runs; see live progress, including text as the model produces it;
answer approval requests; reconnect to a run in progress without losing events.
Building each interface directly on Pipeline would duplicate run management three times,
and each would have to guess the state of a run from files on disk.
Decision
- The engine exposes one seam for interfaces: a run manager in
vibe-pipelinethat starts, resumes, cancels and lists runs and hands out an event subscription. It never renders anything. - Events are the interface contract. Every change an interface shows is an event with
a per-run sequence number and a schema version, persisted in
events.jsonl, so a client can replay from any sequence number and then follow live events. Interfaces keep no private view of engine state that events cannot rebuild. - Human decisions are part of the pipeline, not of an interface: an approval request
is persisted in
run.jsonand resolved by a command (vibe approve,vibe reject) that any interface can issue. - Clients attach in two ways: in process (the CLI and the terminal UI use the run manager
directly) or through
vibe serve, which exposes the same operations over HTTP and the event stream over server-sent events (web UI, editor extensions). .vibe/stays the source of truth and is protected by a cross-process lock, so a CLI and a server can work on the same project.
Consequences
- The terminal UI can ship before the server, and the server adds no engine logic.
- Streaming, approvals and new events must land before any interface that relies on them.
- The event schema becomes a public, versioned API; changing an event is a breaking change.
- A crash loses no decision: approvals and sequence numbers are on disk.
- Content too large for an event stays out of the log but not out of reach: the complete
output of each tool call is written to the trace store,
.vibe/tool-output/<task dir>/<run>/<call>.txt(ignored by git), andtool_returnedreferences it by path. Commits and merges are events too (committed,merged), andrun_finishedcarries the run’s totals, so a history of every run can be rebuilt from events and git alone (Events; decided in ADR-008).
ADR-008: Trace store and read layer
Status: Accepted — 2026-09-27
Context
Up to 0.4, interfaces show a run while it happens, but reconstructing what was done after
the fact is hard. tool_returned carries a 200-character preview and nothing pairs it with
its tool_called when read-only tools run in parallel; the complete output of a call is kept
nowhere unless it exceeds the model’s limit. Commits other than subtask integrations and the
final merge emit no event, and run.json, the only place with a run’s totals, is
overwritten by the next run. Each interface that wanted a history, a project-wide activity
feed or a trace of tool calls would have had to re-parse events.jsonl, guess, and ask git
on its own.
ADR-007 makes events the interface contract and forbids private views that events cannot rebuild, so whatever these views need must be in the events, or referenced by them.
Decision
- Events carry what the views need, as optional fields and new types (schema stays 2):
a
callid ontool_calledandtool_returned, thesubtask, exit code, output length and output path ontool_returned, the model onagent_started,committedandmergedevents, and the run’s totals onrun_finished(Events). - Complete tool outputs go to a trace store, not into the log:
.vibe/tool-output/<task dir>/<run>/<call>.txtat the project root, ignored by git, capped per call (pipeline.trace_max_chars), on by default (pipeline.trace_outputs), removed with the task.tool_returned.output_filereferences the file by a path relative to the project root; nothing else points to it. - One read layer in
vibe-pipelineturns events and git into views:events_log(incremental reading of one log, every task’s log merged by an event cursor),history(runs, commits, changed files, validations, QA, tokens, active time and cost of a task) andtrace(the calls of a run, paired, with their outputs). It stores nothing: every view is rebuilt from.vibe/and git when it is asked for. The command line, the terminal UI and the server all use it, so they agree. - The server reads the logs once.
vibe serveruns one follower of every task’s log, shared by all its open event streams, instead of one reader per client.
Consequences
vibe history,vibe traceandvibe eventswithout a task, and the matching screens of the terminal UI, the web UI and the server routes, are thin renderings of the same documents (TaskHistory,RunTrace,TaggedEnvelope).- The trace store costs disk space in proportion to what the tools printed, bounded per call; it is local, git-ignored and can hold secrets that a command printed.
- Logs written before 0.5 still load: their calls pair by order, their outputs are only the previews, and their totals are unknown rather than zero.
vibe-pipelinenow depends onvibe-workspacefor read-only git queries of the history; the pipeline itself still reaches workspaces throughWorkspaceProvider.- Ordering across tasks relies on the clocks of the processes that log; within a task,
seqstays the exact order.
Events
Everything the engine does is published as an event. Interfaces (the CLI, vibe events,
vibe history, vibe trace, the terminal UI, vibe serve and its clients) are built on
events only, so this page is a public, versioned contract
(ADR-007,
ADR-008).
Envelope
Each event travels in an envelope, one JSON object per line in
.vibe/tasks/<task>/events.jsonl and in vibe --json output:
{"schema":2,"seq":17,"at":"2026-09-27T10:00:00Z","event":{"type":"phase_started","run":"…","phase":"build"}}
| Field | Meaning |
|---|---|
schema | event format version, currently 2; logs written before 0.3 have none and read as 1 |
seq | position of the event in its run, from 1, without gaps and continued across resumes; absent for ephemeral events and events without a run |
at | publication time, UTC |
event | the event, tagged by type |
A client replays a run by reading its events in seq order and follows it by asking for the
events after the last seq it saw: vibe events <task> --after <seq> --follow. Events of
one run are delivered in seq order to live subscribers and to the log.
Ephemeral events are only sent live and never logged: they carry no seq and a client
that reconnects does not get them back.
Adding an event type or an optional field is not a breaking change; clients must ignore
types and fields they do not know. Removing or changing a field increments schema.
Event types
type | Fields | When |
|---|---|---|
run_started | run, task | a run or a resumed run starts |
phase_started | run, phase | before a phase |
phase_finished | run, phase, success, summary | after a phase |
agent_started | run, role, subtask, model | an agent session starts; model is the provider’s model id |
agent_delta | run, role, subtask, delta ({"kind":"text"|"thinking","text":…}) | ephemeral: text arriving while a step streams |
agent_text | run, role, text | the complete text of a step |
tool_called | run, role, tool, input (complete arguments), call, subtask | before a tool runs |
tool_returned | run, role, tool, is_error, duration_ms, preview (first 200 characters), call, subtask, exit_code, timed_out, output_chars, output_file | after a tool ran |
agent_finished | run, role, steps, usage, stop | an agent session ends |
subtask_updated | run, subtask, status | a subtask changes state |
subtask_integrated | run, subtask, commit, conflicts | a finished attempt was merged into the task branch (conflicts empty) or conflicted |
committed | run, subtask, commit, message, files | the pipeline committed in the task workspace: checkpoint before the build, subtask or fix commit, subtask integration |
merged | run, commit, branch, base | the merge phase merged the task branch into its base (auto_merge); commit is the resulting commit of base; branch or base is "" when the workspace provider does not know it |
validation_finished | run, command, integration, passed, exit_code | a required validation command finished |
budget_updated | run, tokens, token_limit, active_ms, duration_limit_ms | after each phase and each subtask attempt |
artefact_written | run, artefact ({"kind":"spec"}, {"kind":"plan"}, {"kind":"qa_report","round":n}) | the spec, the plan or a QA report was written |
approval_requested | run, gate (spec, plan, merge) | the run pauses until a human decides (pipeline.approvals) |
approval_resolved | run, gate, approved, comment | vibe approve or vibe reject answered; logged by that command |
retrying | run, what, attempt, delay_ms | before a retry |
paused | run, reason | the run stops and waits for a human |
run_finished | run, success, status, usage, active_ms, started_at | the run ends: always the last event of a run that published run_started, also after paused and after an error (then success is false, with status failed, or the unchanged task status when the workspace could not be opened); totals of the whole run, resumes included |
log | run (optional), level, message | diagnostics |
Identifiers (run, task, subtask) are UUIDs. role is the agent role (coder,
qa_reviewer, …). Statuses and phases use the same snake case names as vibe task show.
usage is {"input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens"}.
Tool calls
call is a short random id (12 hex digits) generated for each call: it pairs a
tool_called with its tool_returned, including when read-only calls of one step run in
parallel and return out of order. subtask is the subtask the session works on, null
outside the build.
On tool_returned, exit_code and timed_out come from tools that run a command (bash);
other tools leave them null and false. output_chars is the length of the complete
output, before any truncation. output_file points to the complete output (see below), or
is null when tracing is off or the file could not be written.
Trace store
With pipeline.trace_outputs = true (the default), the complete output of every tool call
is kept in .vibe/tool-output/<task dir>/<run>/<call>.txt, where <task dir> is the task’s
directory name under .vibe/tasks/ (003-add-login). output_file gives that path
relative to the project root. Each file holds at most pipeline.trace_max_chars characters
(100 000 by default); a longer output is cut and ends with a marker line. The directory is
ignored by git (.vibe/.gitignore, written by vibe init) and removed by
vibe task discard. It is independent of what the model sees: an output longer than the
runner’s limit is still truncated for the model, with the complete text in
<workspace>/.vibe/tool-output/<uuid>.txt where the agent can read it.
Commits
committed.subtask is null for the checkpoint before the build, for fix commits, and for
one commit shared by several subtasks when subtasks are not isolated. committed.files
lists the paths changed by the commit against its first parent (for a merge commit, what it
brought in), relative to the repository root; it is empty when the committer does not use
git. message is the first line of the commit message. Commits the git workspace makes on
its own are not announced: the checkpoint just before merging and the commit of an assisted
conflict resolution; the merge itself is, by merged.
Logs recorded before 0.5
Every field added in 0.5 is optional when reading, so older logs still load under schema
2: call reads as 000000000000 (pair those calls by order and tool), subtask,
exit_code and output_file as null, timed_out as false, output_chars as 0,
model as "", and run_finished without totals reads usage as zeros, active_ms as
0 and started_at as 1970-01-01T00:00:00Z.
Events of every task
A task’s events.jsonl does not name its task: it is implied by the directory. Views over
the whole project (vibe events without a task, GET /api/events, GET /api/stream, the
Activity screens) read every task’s log and tag each envelope with its task, serialised
flat, the envelope’s fields next to the tag:
{"task":"6f1c3e0a-9b2d-4c47-8a51-0e3b8f2d7c19","number":3,"schema":2,"seq":17,"at":"2026-09-27T10:00:00Z","event":{"type":"phase_started","run":"…","phase":"build"}}
task is the task id and number its number on the board. This shape is
vibe_pipeline::TaggedEnvelope.
Tagged events are ordered by an event cursor: the time at, then the task number, then
seq (0 when absent). It is written <nanoseconds since the epoch>-<number>-<seq>, for
example 1790000000123456789-3-17, and serves as the id of the events of /api/stream. A
client that resumes after the cursor of the last event it saw gets neither a repeat nor a
loss of events of other tasks logged at the same instant; 0-0-0 means “from the start”.
The order across tasks is the order of the at times, and each process stamps its own
events. Events of one task always come in order, but a vibe run in another process whose
clock reads slightly earlier can log an event whose cursor sorts before one already
delivered for another task. A live follower still delivers it, once, so cursors are not
always increasing on the wire; a client that was disconnected while it was logged and
resumes from a later cursor does not get it. When the state of one task must be exact,
reload that task’s events (vibe events <REF>, GET /api/tasks/{ref}/events), which are
ordered by seq.
Roadmap
Vibe Factory is young. The list below is ordered by priority, not by date.
0.1 — foundation (released)
- Layered crate architecture with documented extension points
- Anthropic and OpenAI-compatible providers, mock provider
- Built-in tools with shell security policy and path containment
- Git worktree isolation and merge
- Agent runtime with parallel tools, hooks, context budgeting, continuation
- Spec → plan → build → QA/fix pipeline with file persistence and resume
- Plugin protocol (JSON-RPC over stdio, MCP-compatible tools) and Rust plugin helper
-
vibeCLI - User guide, design book, ADRs, API docs
0.2 — reliability (released)
- Required commands before ready/merge, persistent results and replay on resume
- Feed deterministic validation failures into the bounded QA/fix loop
- Validate the integration candidate, including assisted conflict resolution, before publication
- Ten reproducible Rust evaluation cases with independent oracles, a suite runner and a summary of success, time and tokens
- Worktree per subtask, ordered integration and conflict tests
- Container workspace provider with explicit network, mounts and resource limits
- Persistent token and duration budgets across resumes
- Cancellation-safe plugin writer task
- Cross-platform release qualification and migration guide
Carried over: a published real-model baseline, done in 0.4.0 (Claude Sonnet 5, 2026-09-27: 30/30 runs over the ten cases).
0.3 — interaction foundations and terminal UI (released in 0.4.0)
The engine learns to stream, to wait for a human and to outlive the terminal that started it, then gets a terminal UI. See ADR-007.
- Real-model evaluation baseline, published with the framework commit and model (Claude Sonnet 5: 30/30 runs over the ten cases)
- Streaming completions and live agent text
- Events v2: sequence numbers, schema version, validation, integration, budget and artefact events, replay and follow
- Human approvals after spec, plan or before merge
- Run manager and cross-process locking of
.vibe/ - Terminal UI: task board and live run view
0.4 — server, web UI and integrations (released)
-
vibe serve: HTTP API and server-sent events over the run manager - Local web UI: board, activity, plan, spec, QA, changes, approvals, evaluation dashboard
- GitHub and GitLab issue import and pull request creation
- Web fetch and search tools
- Persistent project memory across tasks
- VS Code extension on
vibe serve
0.5 — visibility: history, global activity, full trace, macOS app (released)
See everything that was done, from every interface (ADR-008).
- Events that tell the whole story: call ids, exit codes and output references on tool
calls, the model of each session,
committedandmergedevents, run totals onrun_finished(schema stays 2) - Trace store: the complete output of every tool call, local and git-ignored
- Read layer: incremental and project-wide event reading with a stable cursor, history rebuilt from events and git, traces of tool calls
-
vibe history,vibe trace,vibe eventsacross every task, optional[pricing] - Server routes for the global event stream, history and trace; web views Activity, History and Trace
- Terminal UI screens Activity and History and a Trace tab
- Native macOS app on
vibe serve(SwiftUI, macOS 14+): signed, notarized DMG with Sparkle auto-update
Later
- OpenAPI description of the server API
- Editing the plan from the web UI before approving it
- Linear and Jira importers
Ideas
- Semantic merge assistance for parallel tasks touching the same files
- Prompt caching statistics and optimisation hints
- A gallery of community agents and plugins
Contributions to any item are welcome — see Contributing.
Contributing
Thank you for considering a contribution. Vibe Factory is meant to be extended, and the best contributions are often plugins and agents rather than changes to the core.
Ways to contribute
- Report bugs and propose features in GitHub issues. Include the
vibe doctoroutput and, for pipeline problems, theevents.jsonlof the task. - Write a plugin. See Plugins. Plugins live in their own repository; open an issue to have yours listed.
- Improve an agent prompt. Prompts live in
crates/vibe-agents/src/prompts/. Explain the failure mode you observed and how the change fixes it. - Improve the documentation. Every page of this book has an edit link.
- Change the framework. Open an issue first for anything that touches a
vibe-coretrait; trait changes must stay backwards compatible (new methods get default bodies).
Development setup
git clone https://github.com/vincentlauriat/vibe-factory
cd vibe-factory
cargo build --workspace
cargo test --workspace
cargo install mdbook && mdbook serve docs
Quality gates
CI runs on Linux, macOS and Windows:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets # with RUSTFLAGS="-D warnings"
cargo test --workspace
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
mdbook build docs
Every public item needs a doc comment. unsafe is forbidden. Tests must not depend on
network access or on the developer’s global git configuration.
Pull requests
- Target
develop;mainis for releases. - One logical change per PR, with tests and documentation updates.
- Use conventional commit messages (
feat(tools): …,fix(pipeline): …,docs: …). - Significant design changes get an ADR in
docs/src/design/adr/.
Code of conduct
This project follows the Contributor Covenant;
see CODE_OF_CONDUCT.md.
Glossary
| Term | Definition |
|---|---|
| Agent | A model running in a loop with tools, described by an AgentSpec. |
| AgentSpec | Declarative agent definition: role, system prompt, tools, model, thinking level, step budget. |
| Artefact | A file produced by a phase (spec.md, plan.json, qa_report.json, …). |
| Base branch | The branch worktrees are created from and merged into. |
| Complexity | trivial, simple, standard or complex; selects the pipeline profile. |
| Event | A structured notification published on the EventBus (phase started, tool called, …). |
| Hook | Extension that observes or vetoes phases and tool calls and can enrich prompts. |
| Memory | Knowledge retained across runs (gotchas, patterns, decisions). |
| Phase | One stage of the pipeline: assess, spec, plan, build, qa, fix, merge. |
| Pipeline | The state machine that drives a task through its phases. |
| Plugin | A bundle of extensions, in-process (Rust) or out-of-process (any language). |
| Profile | The list of phases chosen for a complexity class. |
| Provider | A model back-end (ModelProvider), e.g. Anthropic, OpenAI-compatible, mock. |
| QA report | Structured verdict of the reviewer: approved, changes_requested or inconclusive, with issues. |
| Registry | The collection of every provider, tool, agent, workspace, memory store and hook available to a run. |
| Run | One execution of the pipeline on a task, identified by a RunId. |
| Spec | Specification: summary, requirements with acceptance criteria, gathered context. |
| Subtask | Smallest unit of implementation work, executed by one coder session. |
| Task | The unit of work handed to the framework. |
| Thinking level | Reasoning budget requested from the model: off, low, medium, high, max. |
| Tool | A capability an agent may invoke (read a file, run a command, …). |
| VPP | Vibe Plugin Protocol: JSON-RPC 2.0 over stdio, MCP-shaped. |
| Workspace | The directory a task’s agents are confined to; a git worktree by default. |