The Veyyon handbook
Veyyon is a coding agent that runs in a terminal. Give it credentials for a model provider and it works inside a project: it reads files, runs tools, and edits code in place. Every step that touches the tree goes through an approval tier.
Install
curl -fsSL https://get.veyyon.dev | sh
On Windows:
irm https://veyyon.dev/install.ps1 | iex
The installer downloads a release binary and verifies its checksum. To build from
source, clone the repository and run bun run setup && bun dev in that checkout. See
Install for platforms, pinned releases, updates, and uninstall.
The binary is veyyon, aliased to vey. Configuration lives under ~/.veyyon; the
default profile keeps its state in ~/.veyyon/profiles/default/agent/.
Where things are
| Section | Contents |
|---|---|
| Design and mechanisms | Design goals and the main subsystems |
| Get started | Install, sign in, first task, providers |
| Features | Editing, approvals, models, sessions, plan and goal modes, MCP, plugins, memory, profiles |
| Architecture | Internals |
Implementation
The CLI, TUI, tools, providers, and session loop are TypeScript on Bun. Grep, glob,
PTY, and tree-sitter parsing are Rust natives in @veyyon/natives. The hashline edit
engine is TypeScript in @veyyon/hashline, with native helpers for block resolution.
- Hashline edits.
editandwriteapply content-addressed patches and verify them before anything reaches disk. A failed patch returns a structured error to the model instead of a half-written file. - Model slots. The interactive model (
/model), the subagent model, and the compaction model are separate settings. Named roles pin a model to a kind of work. - Approvals.
tools.approvalModegates the read, write, and exec tiers. There is no operating-system sandbox: no Landlock, no seccomp, no Seatbelt, no bubblewrap. Approvals are the control point. - Engine modes. Plan mode, goal mode, vibe mode, compaction, and task subagents live in the agent loop, not in prompt text.
Veyyon is a fork of oh-my-pi. See Acknowledgements for credits.
Design and mechanisms
- Overview: product capabilities and harness structure
- Mechanisms: subsystem contracts and runtime behavior
- Argot: per-project token shorthand codec
- Performance: execution bounds, native paths, and repair
Install: Install. Concepts: Core concepts.
Overview
Veyyon is a local terminal coding agent. The loop, tools, and credentials run locally on the host machine. Model selection uses the bundled provider catalog via subscription sign-in or API keys.
Subsystem summary
| Area | Capability |
|---|---|
| Edits | Hashline edit and write, checked against file contents before writing to disk |
| Tools | read, search, bash, LSP, DAP, browser, MCP, task subagents, and extensions |
| Approvals | tools.approvalMode gates read, write, and exec tiers |
| Models | Separate slots for interactive, subagent, and compaction models, with role mappings per profile |
| Sessions | Branchable session trees with resume and fork support |
| Memory | Local SQLite memory backends, active when memory.backend is not off |
| Config | ~/.veyyon or profile-specific agent directories; repository trees contribute AGENTS.md instructions |
Lineage
Veyyon is built from oh-my-pi and Pi. See Acknowledgements for credits.
Related
Mechanisms
Hashline edits
The edit and write tools accept hashline patches, which are addressed by content rather than by line number. Before writing, the native layer checks the patch against the current file. If they do not match, the tool fails and returns recovery context to the model instead of writing a corrupted file.
See Editing and repair and The hashline edit engine.
Tool approval tiers
tools.approvalMode is one of plan, ask, ask-command, auto, or yolo.
auto is the default. Older names map onto those: always-ask to ask, write
and auto-edit to ask-command. The tiers are read, write, and exec.
Four guards sit above the mode and no mode lifts them:
- Per-tool overrides in
tools.approval. - The working-directory boundary.
- The secret-use boundary.
bash-guard.ts, which forces a prompt on a destructive command such as a recursive delete of the home directory.yolodoes not lift it.
bashInterceptor.enabled adds a user-configured layer on top, off by default,
matching bashInterceptor.patterns.
See Approvals and /settings -> Interaction ->
Approvals.
Model slots and roles
Model configuration separates model selection from subsystem roles:
- The interactive model is set with
/modelor--modeland persists asmodelRoles.default. - Roles pin a model to specific workloads, such as
smolfor lightweight operations oradvisorfor review. Custom roles are defined inmodelRoles. See Models, roles, and profiles. - Overrides are explicit subsystem policies.
compaction.modeloverrides the interactive model for compaction, otherwise compaction inherits it. Subagent models are configured via subagent policies in settings. - Cycling rotates through
cycleOrder(defaulting tosmolthenslow), bound toapp.model.cycleForward.
Provider-neutral loop
The agent loop, TUI, session format, MCP, skills, hooks, and extensions operate independently of specific model providers. Providers are configured in the active profile agent directory through config.yml or /setup, with account management via /providers.
Engine modes
Compaction, goal continuation, plan mode, vibe mode, and task subagents live in the session and tool layer, not only in prompt text. Goal mode can keep an idle session moving toward a stored objective. Plan mode writes a plan file and holds back mutation until the resolve and approval paths complete.
Profiles
Every profile, including default, lives at ~/.veyyon/profiles/<name>/agent/, which holds its settings, sessions, MCP config, skills, and hooks. See Profiles and File locations.
Related
Argot
Argot is a per-project token shorthand codec for model interactions. A project dictionary maps short handles to recurring text strings such as paths, import roots, and build commands. The model outputs the handle, and the runtime expands the handle to the full string before passing arguments to tools, rendering UI, or appending to transcripts.
For configuration and flags, see Save tokens with project shorthand.
Shorthand format
A handle consists of a sigil prefix followed by an identifier:
- The default sigil is
§. - Identifiers use lowercase ASCII letters, digits, and underscores.
- Per-project sigils are configured in the dictionary’s
sigilfield.
When the model emits §build, the runtime replaces §build with the configured string before executing the command in the shell.
Dictionary structure
Handles are defined in an AGENTS.dict file:
sigil = "§"
[handles]
build = "node --experimental-vm-modules ./scripts/build.mjs --target release --profile ci"
dbconn = "src/server/db/connection.ts"
The dictionary format enforces length bounds on expansions and includes a schema version. Files with incompatible major versions fail load validation.
The dictionary is generated automatically from project contents. When a session starts or the argot_load tool runs on a target directory, the generator identifies recurring strings, ranks candidates by token reduction, and stores the compiled dictionary in the local cache directory. Nothing is written to the repository working tree.
Automatic startup loading is controlled by argot.autoload (enabled by default).
Codec boundaries
The codec operates under two distinct boundaries:
- Decoding: Turning handles back into full text is unconditional. Whenever a dictionary is active, all handles are expanded before text reaches tool execution, transcript storage, or terminal display.
- Encoding: Teaching shorthand syntax to the model is controlled by configuration:
- Model allowlist (
argot.encode.models): Shorthand instructions are provided only to explicitly allowed models. - Context cutoff (
argot.encode.disableAboveTokens): Shorthand instructions are omitted once session context exceeds the specified token limit.
- Model allowlist (
When encoding is disabled, the model writes full strings. Decoding remains active so existing handles in session history continue to expand.
Cache storage
Cache entries are content-keyed using the repository commit hash or a directory fingerprint:
- Cache files are immutable once written.
- Distinct commits produce separate cache entries.
- Stored transcripts contain expanded text rather than raw handles, allowing cache entries to be discarded and rebuilt without corrupting session history.
To rebuild a project cache, remove the cached dictionary directory under ~/.veyyon/cache/argot/.
Subagent boundary
Subagents evaluate shorthand independently:
- Each agent instance expands its output before invoking tools, writing transcripts, prompting subagents, or returning values to a parent agent.
- Raw handles do not cross agent boundaries.
- The
argot.subagentssetting controls whether child agents inherit the parent dictionary, generate a project-specific dictionary, or operate without shorthand. See Subagents configuration.
Related
Harness design goals
Model quality depends on the agent harness: tool schemas, edit format, context handling, and control flow. The same weights can succeed or fail depending on those choices.
Primary mechanisms
- Edit format. Formats that are hard to emit cause apply failures and retries. Hashline (and model-specific edit prompts) is the main write path in
packages/coding-agent. - Control flow. Stop when verification passes; bound retries; budget context and subagent fan-out. Plan mode, goal mode, and tool-approval tiers encode parts of this in the engine.
Design consequences
- Hashline-aware
edit/writewith native verification (see The hashline edit engine). - Explicit model slots and optional roles (
modelRoles, catalog selectors, thinking levels). - Engine-enforced modes: plan file + resolve path, goal continuation, approval tiers.
- Schema-based tool-call repair before argument validation (see Repair).
Related
Architecture at a glance
Veyyon ships as the veyyon CLI (Bun + TypeScript, Rust helpers). Subsystems map
each has a handbook page and matching engineering notes under docs/.
The request path
┌──────────────────────────────────────────────────────────────┐
prompt ──► │ veyyon (packages/coding-agent) │
│ │ │
│ ▼ │
│ AgentSession turn loop │
│ │ model stream → tools (read, bash, edit, …) │
│ ▼ │
│ hashline / handlers ──► filesystem (approval-gated) │
└──────────────────────────────────────────────────────────────┘
Subsystems
| Subsystem | Responsibility | Chapter |
|---|---|---|
Edit engine (@veyyon/hashline) | Default hashline edit path | Edit engine |
| Sessions | Session trees, compaction | Compaction & memory |
| MCP | MCP client integration | MCP |
| Config | Settings and profiles | Config |
| Memory | off / local / mnemopi / hindsight | Memory |
| Goals | Goal cards and budgets | Goal state |
Design rules
- One primary edit path. Hashline is the default edit surface; alternate
edit.modevalues exist for compatibility. - Explicit failures. Invalid config, stale hashline tags, and denied actions return actionable errors to the operator or model. Denied tools do not auto-escalate permissions.
Tool-call argument repair (alias maps, strict unknown-key rejection, parse leniency) runs at the dispatch seam in packages/coding-agent/src/repair/schema-repair.ts. See Repair. Providers and models: Providers.
Performance
Agent execution latency is composed of model generation latency, tool execution I/O, validation failure retries, and harness runtime overhead.
Retry bounds
Tool calls or edits that fail schema validation or patch application require additional model turns. Schema repair on tool arguments and patch validation on edits catch structural issues before execution. Implementation: packages/coding-agent/src/repair/, @veyyon/hashline.
Edit path
Hashline edits reference surrounding anchor lines rather than full file rewrites, so a patch contains the changed lines and their anchors instead of a new copy of the file. Applying one costs O(file bytes + output bytes + patch). The applier splits the body into lines once, walks it once, and joins the result once, so cost tracks file length rather than patch length: about 1ns per input byte, or 3ms for a 100,000-line file on a Ryzen 9 9950X. A file with CRLF endings costs roughly twice that, because the body is normalized to LF before the edit and restored after it. Run bun bench/hot-paths.bench.ts in packages/hashline to reproduce the curve. It prints the cost per byte at 10,000, 100,000 and 1,000,000 lines for a single edit and for a range delete, and fails when that cost stops being flat. See Hashline engine.
Edit preview while arguments stream
A tool call’s edit preview recomputes as arguments arrive. The streaming pass reads its target through a cache keyed by modification time and size, so a stream of chunks against one file reads it once instead of once per chunk; the pass that runs when arguments are complete reads fresh, because that text is what the edit is applied to.
Off-window boundary rows in a diff or a read window (the enclosing header, the matching closing bracket) come from a scan of the whole source, and the parse cache retains nothing past 4MiB. A source over that size renders without boundary rows rather than paying a scan per redraw. Streaming a one-line replacement against an 11.7MiB, 100,000-line file costs 36ms per preview pass and one read, against 1.9s per pass and a read each pass, on a Ryzen 9 9950X.
Session persistence
Entries append to the session JSONL through an open writer. Compaction, elision, a title change and a recovered write fault republish the whole file instead. The body is produced in chunks of about a megabyte and written chunk by chunk, so the transient copy is bounded by the chunk rather than by the transcript. A republish reads the file back first only when what is at the path is no longer the file this session published, compared by inode and by length: a second window writing the same transcript changes both, and reading it back is what keeps its entries. Republishing a 253MiB transcript of 118,000 entries costs 509ms, 44MiB of peak resident memory above the session, and no single pause longer than 3ms, on a Ryzen 9 9950X.
Runtime architecture
The CLI, TUI, and session loop run as TypeScript on Bun. Native grep, PTY handling, shell support, and tree-sitter parsing execute via native addons. Rust crates provide glob matching, grep orchestration, key normalization, text indexing, diffing, and directory walking. Token streaming renders output incrementally as chunks arrive from provider streams.
Related
Install
Veyyon installs as a single self-contained binary. The release installer stages the download and proves it has the published checksum, the requested version, and working native support before it changes the active install or your shell. It then links a short vey launch command next to veyyon. Under the hood Veyyon is a TypeScript and Bun agent loop, with Rust natives handling the hot paths: grep, the file walker, the shell and PTY, and tree-sitter block resolution for hashline block edits. The prebuilt binary bundles all of that, so you do not need Bun, Node, or a package manager to run it.
Install on Linux or macOS
$ curl -fsSL https://get.veyyon.dev | sh
That installs the veyyon binary to ~/.local/bin, links vey beside it, and runs a doctor: self-check. Before it replaces an existing binary, creates the alias, edits a shell profile, or writes completions, it checks the staged download in this order:
- Its SHA-256 digest matches the release sidecar.
veyyon --versionreports the exact release tag you requested.- A real
veyyon grepfinds a known file, proving the native addon loads on this platform.
The checksum proves which bytes you received, but it cannot prove that the release uploaded the right version or a usable native build. If any preflight fails, the installer removes the staged file when it can and leaves the active binary and shell files unchanged. After the verified file moves into place, doctor: repeats the version and native checks from the final path. When ~/.local/bin is not on your PATH yet, the installer then adds it to your shell profile. A profile is read when a shell starts, and the shell you ran the installer from has already started, so the final message gives you the exact reload command before the normal next steps:
The installer records a small ownership receipt beside each binary and completion file it creates. A reinstall or uninstall changes only receipt-backed files. An older Veyyon install is adopted when its exact launcher or generated completion signature identifies it. If another executable or completion already occupies a target path, the installer leaves it byte-for-byte unchanged and reports to move it yourself before retrying, and states the receipt it consulted so you can see what it was comparing against.
A receipt is written before the binary it describes, so a reinstall repairs an install that was interrupted mid-swap instead of refusing it. Installing the same release over a byte-identical binary leaves the file untouched and only rewrites the receipt.
Pass --force (POSIX) or -Force (Windows) to install over a file the installer cannot account for. That file is moved to <name>.unowned.<pid> and its new path printed. Nothing is deleted, and no sweep or uninstall touches that name.
Next steps:
1. Reload your shell: exec $SHELL -l
(or, without a new shell: source /home/you/.bashrc)
2. Launch in any repository: veyyon
3. Connect API providers: veyyon setup
4. See every command: veyyon --help
When the directory was already on your PATH, there is nothing to reload and the list starts at the launch step.
The installer never calls the GitHub API. It finds the newest release from where https://github.com/santhreal/veyyon/releases/latest redirects to, and downloads the binary from that same host. The API is capped at 60 requests an hour per address, shared by everyone behind it, so a CI fleet or an office network that installs Veyyon repeatedly used to start getting a rate-limit failure on a machine where nothing was wrong. Nothing needs a token, and setting one changes nothing about the install.
Install on Windows
irm https://veyyon.dev/install.ps1 | iex
That works in both shells Windows ships: Windows PowerShell 5.1, which is what powershell.exe opens on a stock machine, and PowerShell 7. The installer enables TLS 1.2 before it fetches anything, because 5.1 still offers SSL 3.0 and TLS 1.0 by default and GitHub has required TLS 1.2 since 2018.
Like the Unix installer, it never calls the GitHub API, and it puts the install directory at the front of your user PATH. The one-liner above runs in the window you typed it in, so veyyon works there straight away, with no restart. A PATH entry reaches every other program when that program starts, so terminals you already have open elsewhere will not see it until they restart. The closing steps state which case you are in: run the installer as a file (pwsh -File install.ps1) and it is a separate process whose PATH change cannot reach your shell, so the first step is to open a new window.
Prebuilt release platforms
GitHub Releases publishes these application binaries:
| Operating system | Architecture | Release binary |
|---|---|---|
| Linux (glibc) | x64 | veyyon-linux-x64 |
| Linux (glibc) | arm64 | veyyon-linux-arm64 |
| macOS | x64 (Intel) | veyyon-darwin-x64 |
| macOS | arm64 (Apple silicon) | veyyon-darwin-arm64 |
| Windows | x64 | veyyon-windows-x64.exe |
There is no native Windows arm64 release. On Windows arm64, run the Windows x64 binary under emulation. Linux release binaries require glibc. On a musl system such as Alpine, the installer stops before downloading and reports to clone the repository and build it yourself.
After install
$ vey --version
The first interactive vey opens the first-run setup, which moves through a splash, providers, glyphs, theme, and an outro. To run it again later, use veyyon setup. To re-open just the providers panel inside a session, use /setup. To manage the accounts you already have, use /providers. See Getting started.
Your configuration home is ~/.veyyon, and the default profile keeps its agent directory at ~/.veyyon/profiles/default/agent/.
If an install is interrupted before the final replacement, run it again. The installer stages the binary beside its final path, so a partial download never overwrites an existing veyyon. On Linux and macOS, the verified file takes the live path with one same-filesystem rename. On Windows, the installer moves the old binary aside immediately before replacement and restores it if moving the staged file fails.
Ctrl-C removes the staged file on the way out. A kill the process cannot catch can leave that staged file behind, and the next install reclaims it and reports it:
ok removed /home/you/.local/bin/.veyyon.download.48213 left by an interrupted install (pid 48213)
A staged file belonging to an installer that is still running is left alone, so two installs at once cannot delete each other’s download.
Install a specific release
Linux or macOS
The POSIX installer takes long options. Pass them after -- when you pipe the script:
$ curl -fsSL https://get.veyyon.dev | sh -s -- --help
$ curl -fsSL https://get.veyyon.dev | sh -s -- --binary --ref v1.0.11 # a specific release binary
$ curl -fsSL https://get.veyyon.dev | sh -s -- --ref v1.0.11 # the same thing: --binary is the default
$ curl -fsSL https://get.veyyon.dev | sh -s -- --local # install a binary you built yourself
Windows
The PowerShell installer uses named PowerShell parameters. Create a script block from the downloaded installer so you can pass them:
& ([scriptblock]::Create((irm https://veyyon.dev/install.ps1))) -Help
& ([scriptblock]::Create((irm https://veyyon.dev/install.ps1))) -Binary -Ref v1.0.11 # a specific release binary
& ([scriptblock]::Create((irm https://veyyon.dev/install.ps1))) -Ref v1.0.11 # the same thing: -Binary is the default
& ([scriptblock]::Create((irm https://veyyon.dev/install.ps1))) -Local # install a binary you built yourself
You cannot append parameters to irm ... | iex. Use the script-block form above whenever you need an option. If you downloaded install.ps1 as a file instead, use the same parameters with pwsh -File install.ps1.
Release tags carry a leading v, and --ref 1.0.11 on POSIX or -Ref 1.0.11 on Windows works as well as the leading-v form. The installer looks for the tag you named, then for the v form, and prints which one it resolved to before it downloads anything. It does that only for something that reads as a version. --ref states a published release tag and nothing else, so a branch or a commit is looked up once and then rejected.
Run an unreleased ref, or an unsupported platform
The installer only installs a published release binary. It never clones the repository, never runs bun install, and never builds anything. To run an unreleased branch or commit, or to get Veyyon onto a platform with no release, clone the repository yourself:
$ git clone https://github.com/santhreal/veyyon.git
$ cd veyyon
$ bun run setup # installs workspace deps and builds @veyyon/natives
$ bun dev --version
To pin a ref, check it out before you run setup:
$ git clone https://github.com/santhreal/veyyon.git
$ cd veyyon
$ git checkout v1.0.11
$ bun run setup
$ bun dev --version
Clone it into whatever directory you want it in. That tree is a developer checkout you own: you chose where it lives, you decide when it moves or goes away, and the installer never creates one and never writes into one. bun dev runs Veyyon straight from TypeScript in that tree, so there is no separate build step. Use it while you are evaluating Veyyon or contributing to it.
Building from a checkout needs Bun and Git, and you install those yourself. It also needs git-lfs if the ref you checked out tracks files through Git LFS, because without git-lfs those files arrive as small pointer text files that look present and then fail at runtime.
If you build a release binary in that checkout, you can put it on your PATH with the installer rather than copying it by hand. Pass --local on POSIX or -Local on Windows. That installs the binary you already built, with the same alias, PATH, and completion handling a download gets, and it still clones nothing.
Verify the install
$ vey --version
$ vey plugin doctor
$ vey plugin doctor --fix
vey plugin doctor checks plugin installation health (directories, manifests, entry paths, enabled features). Binary and provider-key checks live in vey setup status. For interactive diagnostics, use /debug in the TUI. See Diagnostics.
When the staged binary would not run
The preflight runs from the staging path inside the install directory. If the binary cannot start or its native search fails, the error includes the exit status and the system error text. A missing shared library means the machine needs that package. A permission error usually means the install directory is mounted noexec, so choose another with VEYYON_INSTALL_DIR. A native-addon load error usually means the release does not support that platform, so clone the repository and build it yourself instead.
This failure occurs before the active binary, alias, PATH, and completion files change. Fix the reported cause and run the installer again rather than trying to finish by hand.
To ask the same questions later, on the machine as it is now, run veyyon setup status.
It repeats the install checks and adds the two the installer cannot make: whether a second
copy of veyyon earlier on your PATH is shadowing this one, and whether the completion
files are still there. It exits non-zero when something is actually broken, so a script can
gate on it. See Diagnostics and health.
Relocate the config directory
On Unix, Veyyon uses ~/.veyyon by default. Two environment variables let you move it. VEYYON_CONFIG_DIR renames the home-relative config directory, and VEYYON_CODING_AGENT_DIR relocates the agent base, which holds config.yml, agent.db, your sessions, and more.
$ export VEYYON_CODING_AGENT_DIR=/path/to/veyyon-agent
$ vey plugin doctor
The File locations chapter shows the full layout.
First credentials
On the first interactive launch, the first-run setup (or veyyon setup) walks you through sign-in and API keys. Inside a session you have three ways to manage credentials: open the setup panel again with /setup, manage the accounts you already have with /providers, run /login (or /login <provider>) for OAuth and key entry, or export the provider’s environment variable and skip the interactive step. See Authentication and Configuring providers.
Updating
Veyyon keeps itself current. On startup it checks GitHub Releases for a newer version, and if it finds one it downloads the new binary in the background:
veyyon 1.2.0 installed · restart to use it
The running process keeps the version it started with, so the update takes effect the next time you launch. On that launch the welcome card’s tip line states the new version and points at what you can do about it:
Tip: Updated to veyyon 1.2.0 · /changelog · roll back or turn auto-update off in /settings
You see it once per update, on the first launch after it. /changelog opens the
release notes on the web rather than printing them into your terminal.
The check costs one request to github.com, and no request to the GitHub API.
It reads the newest version out of where https://github.com/santhreal/veyyon/releases/latest
redirects to, the same way the installer does, because the API is capped at 60
requests an hour per address and that cap is shared by everyone behind it. A
laptop is nowhere near it; an office, a CI fleet or a container host running
several agents spent it on startup checks alone, and then every machine behind
that address reported that it could not check for updates. Nothing here needs a
token, and setting one changes nothing.
The one thing that still queries the API is the version list behind veyyon rollback, because a list of every published version has no redirect to read it
from. That runs when you open the picker, not on startup.
Two settings control this, both on by default:
| Setting | Effect when off |
|---|---|
startup.checkUpdate | No version check runs at all, so nothing updates automatically. |
startup.autoUpdate | Veyyon still reports that a new version exists, but waits for you to run veyyon update. |
Turn automatic updates off like this:
$ veyyon config set startup.autoUpdate false
You can always update on demand, whichever settings are in force:
$ veyyon update
Current version: 1.0.37
New version available: 1.0.38
ok Checksum verified
ok Updated to 1.0.38. Restart veyyon to run it.
Changelog for 1.0.38: https://veyyon.dev/changelog#v1-0-38
The last line is the same changelog link veyyon rollback prints, so however you
change version you are told where to read what changed. If an update fails,
Veyyon points you at veyyon rollback in the same breath, since a failed update
is the moment you most want the way back.
A checkout install uses the same recoverable contract. That is a veyyon on your PATH that runs out of a git clone you made yourself. Before it fast-forwards, Veyyon requires a clean tracked tree and records the current Git revision. If dependency installation, generated artifacts, native provisioning, version verification, or the runtime search probe fails after the merge, it resets to that revision, restores the old dependencies and generated artifacts, and proves the restored launcher runs before it reports the failure.
Going back to an older version
If a release breaks something you depend on, you do not have to wait for the next
one. veyyon rollback moves your install to any published version.
Run it with no arguments and you get a picker over every published version:
$ veyyon rollback
The list opens on the version you are running. Type to filter it, press c to
open the highlighted version’s changelog in your browser, and press enter to
choose one. Nothing installs until you choose, and the change takes effect the
next time you launch.
If you already know the version you want, or you are writing a script, the same command works without the picker. Start by seeing what there is:
$ veyyon rollback --list
VERSION PUBLISHED
1.3.0 2026-07-01 (newer)
1.2.0 2026-06-01 (current)
1.1.0 2026-05-01 (previously run)
The markers tell you where you stand: current is the version running now,
newer is a version you would move forward to, and previously run is one this
machine has been on before. Every version change is recorded, whether it came
from an update, from a background automatic update, or from a rollback, so
previously run marks the whole path this install has taken rather than only the
times it went backwards. Then name the one you want:
$ veyyon rollback 1.1.0
That installs 1.1.0 the same way an update installs a new release, verifies the binary really is the version it claims, and prints the changelog link for it. Like an update, it takes effect the next time you launch.
Two things it will not guess at. Rolling back to the version you are already running does nothing useful, so it reports that instead of reinstalling and reporting success. And a source checkout cannot be rolled back: it updates by fast-forwarding its git branch, which only moves forward, so Veyyon reports that rather than quietly reinstalling the latest version. To run an older version from a checkout, check the tag out yourself, or install the binary build and roll back from there.
Add --json to --list when you want the same information for a script; each
row contains the version, its publish date, the markers, and the changelog URL.
Without a terminal on both ends, the bare veyyon rollback prints the list
rather than opening a picker nothing can drive, so it is safe in a pipeline.
Building that list is the one thing Veyyon queries the GitHub API for, so it is also
the one thing that can be rejected because of the API’s per-address limit. When it
is, the error states what failed and what still works: updating forward does not touch
the API, so veyyon update is unaffected. Wait a few minutes and the list comes
back.
You can also reach the picker without leaving a session. Open /settings, go to
the Interaction tab, and you will find Roll back version directly under
Automatic Updates, showing the version you are running now. It opens the same
picker, and choosing a version closes the settings panel first so you can watch
the install and read anything it has to tell you. The row appears only on an
install that can actually perform the move, so you will not see it on a source
checkout.
Veyyon is distributed only two ways, and it updates the way it was installed. A
binary install fetches its replacement from GitHub Releases. The updater stages
the download beside the live executable, then performs the same ordered
preflight as the installer: published SHA-256 checksum, exact release version,
and a real native-backed search. The search is skipped only when rolling back to
an old version that has no veyyon grep command, which the staged binary must
confirm through its own --help. If any preflight fails, the staged file is
removed and the binary you started with stays live.
After preflight, Veyyon preserves the current executable as a backup without removing its live path, using a hard link where the filesystem permits it and a completed copy otherwise. One atomic rename then switches the live path to the verified replacement. A hard kill can therefore leave the old binary or the new one at that path, but never no binary. If the final installed check fails, the backup is atomically restored. A backup that is still locked on Windows, or is left by a hard kill, is reclaimed by a later update.
A source checkout updates in its own terms: veyyon update fast-forwards the
checkout, reinstalls dependencies, regenerates build artifacts, and refreshes
the native addon, all in one command. It then reads the checkout’s own version
back and will not report success unless the checkout really is at the new
release. A fast-forward only advances the branch you are on, so a checkout on a
feature branch, or on a fork whose upstream lags, can merge cleanly and stay
behind; Veyyon reports that instead of claiming a version you do not have. The
background updater leaves source checkouts alone and never runs git against your
working tree. It reports that a version exists, and you run veyyon update when
you want it. There is no npm, Homebrew, or other package-manager channel to go
through. If an update fails, Veyyon reports the failure and the retry command veyyon update; it never fails quietly and leaves you on an old version without a word.
Veyyon works out which of the two you have by following the veyyon on your
PATH to what it really runs. A symlink is followed, and so is a small wrapper
script that hands off to something else: if what it hands off to is a checkout’s
launcher, the install runs from that checkout and gets the checkout update. That
matters if you keep your own wrapper in front of a checkout, to set an
environment variable or pick a different interpreter, because without following
it Veyyon would treat the wrapper as a binary and overwrite it with a downloaded
release, leaving your checkout orphaned. A wrapper is recognized on either
platform: a .cmd or .bat file, or any file starting with #!. The release
binary itself is never read looking for one.
If the same version fails to install twice, the cause is usually the machine
rather than the release: a binary owned by another user, a read-only image, or a
directory that needs elevated permissions to write. Veyyon reports that failure
and then leaves it alone for six hours instead of repeating it on every launch. A
newer release is never held back by an older one’s failure, and veyyon update
ignores the pause entirely, so you can always ask to see the error again:
$ veyyon update
An update also rewrites the shell completion files you already have, so tab completion covers the new version’s subcommands and flags. It rewrites only files that are already there because the installer chooses which shells are wired. If a file cannot be rewritten, a manual update states the path and that it still describes the previous version. A background automatic update adds a visible warning to the TUI update notice, counts the stale completion files, and tells you to re-run the installer to rewrite them. The binary update remains installed. A binary update generates completions from the new binary; a source update generates them from the checkout’s launcher.
The native addon is cached per version under ~/.veyyon/natives/<version>/,
around 150MB each. When a new version stages its own cache, the previous
version’s copy is removed: it can never be loaded again, because Veyyon looks
only under its own version. Only directories named like a version are touched,
and a copy that cannot be removed is reported and retried on the next update.
Running several sessions at once is safe. Only the first one to start installs; the others see that an install is under way and skip it rather than writing over the same binary at the same time.
Tab completion
The installer sets up tab completion for you. On macOS and Linux it writes one
file per shell into the directory bash, zsh, and fish each autoload from. If a
shell will not load that directory (zsh’s $fpath often does not include it,
and bash needs the bash-completion package), the installer reports it and prints
the exact line to add, rather than leaving you a file nothing reads.
Completion covers more than the command names. It offers the models in the
catalog for --model, your saved sessions for --resume, and for veyyon config it offers the settings that exist and the values each one accepts:
$ veyyon config set startup.<Tab>
startup.autoUpdate startup.checkUpdate startup.quiet startup.setupWizard
$ veyyon config set startup.autoUpdate <Tab>
true false
Those candidates come from the installed binary itself, so they describe the version you are running rather than the version the script was written for. A value only you know, an API key or a search term, is left alone: completion offers nothing rather than a list of your files.
Attachments complete as paths. A word starting with @ specifies a file to send
along with your message, so the shell completes it the way it completes any
path:
$ vey @src/ma<Tab>
@src/main.ts
Windows works differently, because PowerShell has no directory it autoloads
completions from. The installer writes veyyon-completions.ps1 next to your
profile and adds one line to the profile that loads it:
# added by the veyyon installer
. "C:\Users\you\Documents\PowerShell\veyyon-completions.ps1"
Uninstall removes that line and the script, and leaves the rest of your profile exactly as it was.
If you already have your own vey command, the installer never creates that
alias, and the completions it writes do not bind the name either. Every
generated script normally completes both veyyon and vey, so binding it
anyway would give your tool Veyyon’s subcommands. You can ask for that form
yourself:
$ veyyon completions zsh --no-alias
Updates keep that decision. When Veyyon rewrites your completion files it reads
the ones already there to see whether they bind vey, and regenerates them the
same way, so an update never starts completing a command that is not ours.
Uninstall
The installer removes everything it added, and only what it added: the binary, the vey alias, the shell completions it wrote, the cached native addon, and a source checkout if you made one.
The PATH line goes too. When the install directory was not already on your PATH, the installer appended two lines to your shell profile: a comment naming itself, and the line that adds the directory. On bash and zsh the pair looks like this, with the directory in single quotes so a name containing $, a backtick or a space is used literally rather than expanded when the profile is sourced:
# added by the veyyon installer
export PATH='/home/you/.local/bin':"$PATH"
On fish it is fish_add_path '/home/you/.local/bin' instead. Uninstall removes that exact line, and the comment directly above it when the comment is still there, and nothing else: a line you wrote yourself that happens to name the same directory stays. Installs made before the quoting was added wrote export PATH="/home/you/.local/bin:$PATH", and uninstall recognizes that older form too, so upgrading and then uninstalling does not strand a line in your profile.
Because a profile is read when a shell starts, the shell you ran the uninstall in still has the old entry on its PATH, and bash and zsh also remember where they last found a command. The uninstall reports it:
veyyon uninstalled.
your shell keeps the old PATH entry until it reloads: exec $SHELL -l
Without that, typing veyyon straight after uninstalling answers “No such file or directory” for a path you can see is gone, which reads as a half-finished uninstall.
It also reclaims what an UPDATE may have left. An update stages the new binary
beside the old one and keeps the one it replaces as a backup until the new one
has proved itself, and on Windows that backup cannot be deleted while the process
holding it is still running, so a veyyon.<id>.new or a veyyon.<id>.bak can
outlive the update that made it. Uninstall removes those too, so the install
directory is left empty rather than holding a few hundred megabytes you have no
name for. A backup you saved yourself under a name of your own is left alone.
Two things it deliberately leaves behind. If you already had your own vey command, the installer never created that alias in the first place (it reports that at install time and prints the veyyon command instead), so uninstall does not touch it or its completion file. And if a checkout at ~/.veyyon/src has uncommitted edits or commits on a local branch that is on no remote, it is moved to ~/.veyyon/src.bak-<timestamp> instead of being deleted, so nothing you wrote is lost. Older installers created that tree. The current installer never does, so uninstall only ever cleans up one an older version left behind.
$ curl -fsSL https://get.veyyon.dev | sh -s -- --uninstall
On Windows:
& ([scriptblock]::Create((irm https://veyyon.dev/install.ps1))) -Uninstall
Then remove your state if you want a clean machine:
$ rm -rf ~/.veyyon # irreversible: config, secrets, sessions, plugins, skills, logs
$ # if you relocated the agent base:
$ rm -rf "$VEYYON_CODING_AGENT_DIR"
Signing in
Veyyon authenticates to whichever provider you point it at and calls provider APIs directly with keys
you supply. Optional OpenTelemetry export runs only when OTEL_EXPORTER_OTLP_* is configured. Logins
are provider-scoped: authenticating anthropic does not authenticate openai, and each provider
tracks its own credentials. Those credentials are shared across profiles by default (see
Credentials are shared across profiles).
Sign in from the TUI
Use the interactive slash commands inside a session:
/login: opens the OAuth/key selector./login <provider>: jumps straight to one provider, e.g./login anthropic,/login github-copilot./login <redirect-url>: completes an OAuth flow that needs a pasted callback URL./logout: opens the provider selector to remove stored credentials.
On first run, the first-run setup (veyyon setup, or /setup later) walks the same flow.
Using several accounts for one provider
You can sign in to the same provider more than once. Run /login anthropic twice with two different
logins and Veyyon stores both, each with its own quota. To see them, open the account manager:
/providers
The sidebar lists your providers and the body lists that provider’s accounts, one row each, with the
email, the plan, how the credential was supplied, and how much of its quota is spent. A row marked
this session is the one serving your current session.
From that card you press enter to use the selected account, n to name it, r to re-check its
health, u to open its usage, x twice to log it out, and a to add another account for the same
provider. /account manager opens the same card.
The last row of the list is + add another … account. It is a position in the list like any other:
arrow down past your last account to land on it, and press enter there to start a login. The
mouse works on the card as well. Click an account to select it, click that last row to start the
login, and click any key chip in the footer to run what it names.
The provider list filters. Press ctrl+s, type part of a provider’s name or id, and the sidebar
keeps the providers that match; the arrows move within them and enter still switches to the
selected account. Press esc to leave the filter and get the full list back, and esc again to
close the card.
Switching is per provider. Choosing another Anthropic account changes Anthropic and nothing else,
because several providers serve one session at the same time: your main model, your subagent roles,
and web search can each be a different provider. Moving between providers is a model choice, so it
lives in /models.
Naming an account
An email is not always the thing you recognise, especially when two subscriptions share one login. Give an account a name and every surface uses it:
/account name work
The name belongs to the account, not to the stored token, so it survives a token refresh and a later re-login to the same account. An account you never named shows its email instead, and Veyyon tells you how to set one.
Which account am I using?
/account
This reports one line per provider your session has actually routed to, with the account it is using and that account’s remaining quota. A provider you hold credentials for but have not used this session is not listed.
If your chosen account hits its rate limit, Veyyon moves to another one so your work continues, and reports it:
Anthropic personal main model (opus-5)
pinned to work, rotated off it (usage limit)
/account switch anthropic to re-pin work · 2h 14m until it unblocks
Your choice is kept, not discarded. Once the limit resets, traffic returns to the account you picked with no further action.
When a login is signed out for you
A stored login can stop working without you doing anything: the provider revokes the grant, or a token refresh fails and the credential is set aside. Veyyon does not hide that. The account manager marks the provider and prints the provider’s own reason, so you can tell a revoked grant from a temporary outage:
Kimi Code · 1 account
a previous login was signed out: oauth refresh failed:
invalid_grant: The provided authorization grant is invalid
press a to sign in again
If that provider had only one login, it now has none, and the card states that rather than showing the
provider as one you never signed into. /account shows it too, so you do not have to open the card
to find out:
1 provider has a signed-out login (Kimi Code) · /providers to sign in again
A logout you performed yourself is not reported this way. You already know about it.
Headless and remote hosts
For CI, servers, or a shared team credential store, use the auth broker from the shell:
$ veyyon auth-broker login <provider>
$ veyyon auth-broker status
$ veyyon auth-broker list
$ veyyon auth-broker logout
import and migrate are also available. See Providers and docs/handbook/src/architecture/secrets.md
for the broker model.
Using an environment variable instead
Every API-key provider reads one or more environment variables, so a key already exported in your shell (or in
a .env file) is used without an interactive sign-in. OAuth-only providers (for example google-antigravity, google-gemini-cli, kimi-code) take no key variable: sign in with /login.
| Provider | Environment variable |
|---|---|
openai | OPENAI_API_KEY |
anthropic | ANTHROPIC_API_KEY (or ANTHROPIC_OAUTH_TOKEN) |
google | GEMINI_API_KEY |
deepseek | DEEPSEEK_API_KEY |
moonshot | MOONSHOT_API_KEY |
zai | ZAI_API_KEY |
openrouter | OPENROUTER_API_KEY |
xai | XAI_API_KEY |
groq | GROQ_API_KEY |
mistral | MISTRAL_API_KEY |
The full provider → variable map lives in Providers. .env files are loaded
from <cwd>/.env, ~/.veyyon/profiles/default/agent/.env, ~/.veyyon/.env, and ~/.env, with earlier sources winning.
How keys are resolved
When a provider needs a key, Veyyon resolves it in order (first match wins):
- A runtime
--api-keyfor the current process (never persisted). - A
models.ymlapiKeyon a custom provider. - A stored OAuth credential (refreshed as needed).
- A stored API key in the auth store (persisted by
/login). - The provider’s environment variable (including
.env). - Any other stored API-key credential, then a custom-provider resolver fallback.
Stored credentials live in a machine-wide auth store at ~/.veyyon/shared-auth/agent.db (or the configured
auth-broker snapshot in broker mode). VEYYON_CODING_AGENT_DIR relocates the agent base for a profile’s own
files, but the shared auth store stays at the global config root so every profile reads the same logins.
Credentials are shared across profiles
By default every profile reads one machine-wide set of provider logins, so signing in once works everywhere. The first time a profile opens the shared store, any login already saved in that profile is promoted into it, so turning sharing on never signs you out.
To give a profile its own private credentials instead, turn sharing off in the global config
~/.veyyon/config.yml:
profileSharing: false
or toggle Share Credentials Across Profiles on the Global tab of /settings. With sharing off, each
profile keeps its logins in its own ~/.veyyon/profiles/<name>/agent/agent.db and never reads another
profile’s credentials. The auth broker (above) is a separate cross-host mechanism and is unaffected by this
setting.
Provider data is data-driven
Provider identity (display name, env var, OAuth parameters) and endpoints (base URL, API kind) come
from the bundled model catalog plus your ~/.veyyon/profiles/default/agent/models.yml. A new BYOK provider becomes
selectable by adding a providers: entry, not by changing code. See
Configuring providers and docs/handbook/src/reference/providers.md.
See also: Models and providers and the CLI reference.
Quickstart
This is the short path: install Veyyon, start a session, and make one small edit. For the full walkthrough, see Getting started.
Before you start
Check whether Veyyon is already on your machine:
which vey
vey --version
If it is missing, the one-command installer wires up your PATH, shell completions, and the vey alias:
curl -fsSL https://get.veyyon.dev | sh
You can also pin a release binary with curl -fsSL https://get.veyyon.dev | sh -s -- --ref v1.0.11. The installer only ever downloads a published release binary. To run an unreleased ref instead, clone the repository yourself and run bun run setup && bun dev in that checkout. See Install.
Check the environment
veyyon plugin doctor
Inside the TUI, /debug opens interactive diagnostics. See Diagnostics and health.
By default, your config and sessions live under ~/.veyyon/profiles/default/agent/. Two environment variables move them: VEYYON_CONFIG_DIR renames the home-relative config directory, and VEYYON_CODING_AGENT_DIR relocates the agent base. Named profiles use ~/.veyyon/profiles/<name>/agent/.
Start your first session
cd my-project
vey
The first interactive launch shows the first-run setup (splash, providers, glyphs, theme, outro), then the welcome screen and composer. Resuming a session, or setting VEYYON_SKIP_SETUP=1, skips it. You can re-open provider setup later with /setup, or run veyyon setup from the shell. To manage the accounts you already have, use /providers.
After setup, you should see the TUI composer, the model indicator, and your workspace path.
Ask for a small edit
> Add a name argument to greet() in greet.py, default 'world'.
Veyyon reads the file, proposes an edit or hashline change, and may pause for approval depending on tools.approvalMode. Choose Approve (or Deny) and press Enter when it prompts you.
Composer conveniences
A few keys do a lot in the composer:
@completes file paths (fuzzy file references)./opens the slash commands, such as/help,/tree, and/settings.Escinterrupts a running turn./hotkeyslists the active keyboard shortcuts.
Next steps
You now know the loop: start veyyon, ask, approve the tools, and inspect the diffs.
Getting started
Four steps take a fresh machine to a first change in a project: install, run the first-time setup, sign in to a provider, hand Veyyon a task.
1. Install
The quickest path is the one-line installer, which downloads a self-contained binary and links a short vey command.
$ curl -fsSL https://get.veyyon.dev | sh # Linux or macOS
$ vey --version
On Windows, run irm https://veyyon.dev/install.ps1 | iex instead.
The installer only downloads a published release binary. If you would rather build from a checkout, clone the repository yourself, into any directory you like, and run the setup script there.
$ git clone https://github.com/santhreal/veyyon.git
$ cd veyyon
$ bun run setup
$ bun dev --version
bun run setup installs the workspace dependencies and builds @veyyon/natives, the Rust addon. That checkout is yours: you chose where it lives, and the installer neither creates it nor writes into it. Your configuration lives under ~/.veyyon, and the default profile keeps its agent state in ~/.veyyon/profiles/default/agent/.
The installer sets up shell completions for you when your shell supports them. The Install chapter has the full details.
2. First launch
The first time you run veyyon (or any time you run veyyon setup), the setup UI walks you through five steps:
- The splash screen.
- Providers, where you sign in or paste an API key, and optionally enable web search.
- Glyphs, where you choose a Nerd Font, plain Unicode, or ASCII.
- Theme.
- The session welcome.
You can return to provider setup later with /setup inside the TUI, and manage the accounts you already have with /providers. To skip setup entirely, set VEYYON_SKIP_SETUP=1, or resume an existing session.
3. Sign in to a provider
Veyyon needs at least one model provider. You have three common options.
Use an API key. Set the provider’s key in your environment, then pick one of its models in /model. For example, export DEEPSEEK_API_KEY and choose a DeepSeek model.
Sign in with OAuth. Run /login, or name a provider directly with /login anthropic. These are the same flows the Providers setup scene uses.
Add a custom gateway. Declare a provider in ~/.veyyon/profiles/default/agent/models.yml:
providers:
my-gateway:
baseUrl: https://gateway.example.com/v1
api: openai-completions
apiKey: MY_GATEWAY_API_KEY
models:
- id: claude-sonnet
name: Claude Sonnet via Gateway
contextWindow: 200000
maxTokens: 8192
Run a local model. With the Ollama daemon running, Veyyon discovers local models and needs no key:
$ ollama serve
$ veyyon
Open /model and choose an ollama/... entry from the discovered list.
For the full picture, see Models and providers and Configuring providers.
4. Run your first task
Change into a project you know, and start Veyyon.
$ cd ~/code/my-project
$ veyyon
Describe a small, checkable task:
Add a function add(a, b) in src/lib.rs and a unit test. Run the test.
A typical run looks like this:
- Veyyon reads the files it needs with
readandsearch. - It proposes an edit through the hashline
editandwritetools. - When your policy requires it, you approve the tool call. The
tools.approvalModesetting sets when this happens; see Safety. - The change lands, and the diff appears in the TUI.
- If you asked for tests, Veyyon runs them with
bash; under the defaultautomode that call runs without a prompt, and a stricter mode prompts you first.
Approval mode
Every tool call falls into one of three tiers: read, write, or exec. The tools.approvalMode setting sets which tiers run without asking and which ones prompt you first.
| Mode | Runs without asking | Prompts you for |
|---|---|---|
plan | read (it proposes, but does not write) | write only inside an active plan-mode session; write and exec are otherwise denied |
ask | nothing | every call, read included |
ask-command | read and write | anything that executes: bash, eval, browser, task, ssh |
auto (default) | all tiers | a per-tool policy, a path outside the working directory, credential use, and a tool’s own flagged calls |
yolo | all tiers | a blatantly destructive command, and a per-tool deny or prompt |
The older names still work: always-ask maps to ask, and write and auto-edit both map to ask-command. The schema default is auto. You can change it in /settings or in your config. See Approvals and Safety.
Where to go next
A few surfaces are worth trying early:
- A multi-file change. Ask for a refactor across modules. Hashline edits batch the paths together.
- The session tree.
/treejumps back to an earlier message and branches from it inside the same session file. - Model slots.
/modelsets the interactive model. You set the subagent and compaction models in settings; see Models, roles, and profiles.
From here, the Quickstart is a shorter walkthrough, Configuration covers settings, Sessions explains resuming and branching, Memory covers the mnemopi backend, and Diagnostics covers the doctor and debug tools.
Configuring providers
Copy-paste setups for bring-your-own-key (BYOK) and local providers. Once a provider works, see Models and providers to choose and switch models. For what the harness owns versus what the provider owns, see Model contract.
Custom providers live under providers: in ~/.veyyon/profiles/default/agent/models.yml. Keys are resolved from the
environment, stored auth, OAuth, or a models.yml apiKey (see Providers
and docs/handbook/src/reference/providers.md).
Anatomy of a provider entry
# ~/.veyyon/profiles/default/agent/models.yml
providers:
acme:
baseUrl: https://api.acme.example/v1
api: openai-completions
apiKey: ACME_API_KEY # env-var name; `literal:text` for verbatim text
models:
- id: acme-coder
name: ACME Coder
contextWindow: 128000
maxTokens: 8192
| Field | Meaning |
|---|---|
baseUrl | OpenAI-compatible API root |
api | Request shape, e.g. openai-completions |
apiKey | Env-var name or literal; prefix with ! to run a shell command and use its stdout |
auth: none | Mark a keyless local provider |
authHeader: true | Inject the resolved key as Authorization: Bearer <key> |
models | List of { id, name, contextWindow, maxTokens } entries |
Notes worth knowing:
- Custom providers are merged alongside built-ins; they do not silently replace
openai. - A custom
ollama/lm-studio/llama.cppentry replaces that engine’s built-in discovery. - A YAML or schema error makes the registry skip the file with an error message: validate with
veyyon models.
After editing, restart the session.
OpenAI (API key)
$ export OPENAI_API_KEY=sk-...
$ veyyon --model openai/gpt-5
OpenAI is API-key only: export OPENAI_API_KEY or pin apiKey in models.yml. See Authentication.
DeepSeek
providers:
deepseek:
baseUrl: https://api.deepseek.com
api: openai-completions
apiKey: DEEPSEEK_API_KEY
models:
- id: deepseek-chat
name: DeepSeek Chat
contextWindow: 128000
maxTokens: 8192
$ export DEEPSEEK_API_KEY=sk-...
$ veyyon --model deepseek/deepseek-chat
deepseek is also a built-in catalog provider; the env var alone is enough if you do not need a custom
endpoint.
OpenRouter (OpenAI-compatible gateway)
$ export OPENROUTER_API_KEY=...
$ veyyon --model openrouter/anthropic/claude-sonnet-4
Model ids are whatever OpenRouter lists; Veyyon discovers them at runtime.
Anthropic
Anthropic is a built-in provider. Sign in with /login anthropic (OAuth) or set ANTHROPIC_API_KEY:
$ export ANTHROPIC_API_KEY=sk-ant-...
$ veyyon --model anthropic/claude-sonnet-4-5
To reach Anthropic models through a gateway instead, add an OpenAI-compatible custom provider (OpenRouter, LiteLLM, a team proxy) and select the gateway’s model id.
Other OpenAI-compatible hosts
Any host that speaks Chat Completions works the same way, only baseUrl, api, and apiKey change:
providers:
my-proxy:
baseUrl: https://llm-proxy.example.com/v1
api: openai-completions
apiKey: PROXY_API_KEY
authHeader: true
models:
- id: coder-large
name: Org Coder Large
contextWindow: 200000
maxTokens: 8192
$ export PROXY_API_KEY=...
$ veyyon --model my-proxy/coder-large
Amazon Bedrock
Bedrock is a built-in provider. Use the usual AWS credential chain (AWS_PROFILE, instance role, or
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY) expected by the AWS SDK on your machine.
Ollama (local)
Ollama is discovered automatically and is keyless when the daemon is running. Default base URL
http://127.0.0.1:11434 (override with OLLAMA_BASE_URL).
$ ollama serve
$ ollama pull llama3.2
$ veyyon # then /model and pick an ollama/… entry from discovery
LM Studio (local)
LM Studio (lm-studio) is also discovered automatically and keyless by default. Default base URL
http://127.0.0.1:1234/v1 (override with LM_STUDIO_BASE_URL).
$ lms server start
$ veyyon # then /model and pick an lm-studio/… entry
Pinning models for roles and CI
Set the interactive model and optional roles in the profile config.yml (modelRoles.default is the interactive model):
# ~/.veyyon/profiles/default/agent/config.yml
modelRoles:
default: openai/gpt-5
smol: openai/gpt-5-mini
task: deepseek/deepseek-chat
For a hermetic CI run, pass an explicit model and a one-shot config overlay:
$ veyyon --config ./ci-settings.yml --model openai/gpt-5-mini \
"summarize the staged diff in five bullets"
Verify
$ veyyon models
$ veyyon models refresh <provider>
$ veyyon --model <provider>/<id> "reply with the model name you are"
The provider form bypasses the cache and contacts only the named provider. Omit it when you need to refresh every configured catalog.
If discovery or auth fails, the error states the provider and the missing key or unreachable base URL, fix that rather than retrying with a different silent default.
See also
Core concepts
The vocabulary for how Veyyon runs: sessions, permissions, and the boundary between the harness and the model provider. For operator commands see Using Veyyon; for feature guides see Features.
Pages
The CLI is veyyon. It calls a configured model endpoint with your credentials and runs a tool loop: read, edit, verify, stop. The pages below define the units and contracts of that loop, so that later chapters can assume you know them.
| Page | What it defines |
|---|---|
| Sessions, turns, and threads | The runtime units. A session is the persisted run, a turn is one request plus the agent loop, and a thread is the active path through the session tree. |
| Permission model | The approval-mode boundary. tools.approvalMode (plan, ask, ask-command, auto, yolo) sets which tool tiers run automatically and when Veyyon prompts you first. There is no operating-system command sandbox. |
| Model contract | The bring-your-own-key boundary: endpoint, model, and key. It covers what the harness owns versus what the provider owns, Freeform versus Function tools, and how system prompts and tool schemas are presented. |
Foundations that pair with these pages
The foundations pages give the design spine without repeating the operator workflow.
- Architecture at a glance maps the subsystems to their responsibilities.
- For provider and model configuration, see Providers and
docs/handbook/src/reference/providers.md.
How the pieces fit
you ──► veyyon (TUI or a one-shot prompt)
│
├─ session / thread / turn (concepts/sessions-turns-threads)
├─ approval mode (concepts/permission-model)
└─ model call (concepts/model-contract)
│
├─ system prompt + tool schemas (harness)
├─ endpoint + key (your provider)
└─ model id (discovered or pinned)
Changing providers changes the endpoint, the credentials, and the model id. Tool repair, edit verification, approvals, and context compaction stay the same, because they are harness behavior. See Configuring providers and Models and providers.
Related reading
- Permission model and Approvals cover the approval modes.
- Approvals usually need no tuning: the default
autoruns every tier while the per-tool policies, the working-directory boundary, credential use, and a tool’s own critical commands all still prompt. Pick a rung explicitly only when you want a different trade-off, and note that a headless run has nobody to answer a prompt, so a rung that prompts turns a tool call into a failure rather than a pause. - Non-interactive mode covers scripted
veyyonlaunch patterns.
Sessions, turns, and threads
A Veyyon run is a loop of user requests and agent responses. The session holds the whole run. Each turn is one request and the agent loop that answers it. A thread is one path through the session tree. These three ideas are the foundation for branching, plan mode, and long-running context.
Lifecycle of a run
start session (veyyon / veyyon "prompt" / resume)
│
▼
compose prompt ──► turn begins
│
├─ assemble context (instructions, goal card, recent history, tools)
├─ call model
├─ dispatch / repair tool calls (edit, exec, MCP, …)
├─ approval-mode gate
└─ final reply ──► turn ends (or Esc abort)
│
▼
append rollout entry ──► update active leaf
│
├─ next user message ──► next turn
├─ /compact when the window is tight
└─ /fork / /branch / /tree when exploring branches
Interactive veyyon, a non-interactive veyyon "prompt" run, and resume paths all share this loop.
The difference is who supplies the next prompt and whether a TUI is attached.
What a session is
A session is the unit of interactive work. Start one with veyyon in the repository you want to change. The session records every turn, tool call, approval, edit, and verification result.
Sessions are stored as append-oriented rollout JSONL files. Each history entry has an id and a parentId, which makes the rollout a tree rather than a linear chat log. New history is appended. Header or representation maintenance may atomically rewrite the file, but it preserves every history entry.
What a turn is
A turn is one user request plus the agent loop that responds to it. The loop calls the model, dispatches any tool calls, and produces the final reply. A turn ends when the model stops or when the harness decides to stop it.
While a turn runs you can steer it with Enter or queue a follow-up with ctrl+q or ctrl+enter. A queued follow-up becomes a new turn after the current one finishes. Interrupting with Esc aborts the turn and returns queued messages to the composer.
Threads and the active leaf
At any moment, one path through the session tree is active. That path is the thread. The active leaf is the current entry at the end of that path.
Branching creates siblings in the tree. /tree browses every entry, including abandoned branches.
/branch copies history up to a chosen user message into a new session file. /fork duplicates the
entire current session into a new file (no entry picker). There is no /clone command. The
original session is never modified.
Context pressure and compaction
Models have a finite token window. As a session grows, the raw transcript may no longer fit. Veyyon compacts history into a smaller, information-preserving summary instead of truncating it.
Compaction preserves the goal card, active user instructions, recent turns, and a deterministic working-set of files touched so a resumed session does not require the full raw transcript.
Prefer /compact when you need a summary to retain state. Prefer the /new command when prior transcript is no longer useful and you want a clean session without summarization. See Slash commands.
The rollout
Session history lives in one layer: the JSONL rollout. Every event is one line: a user message, an agent response, a tool call, a compaction, a goal update, or a branch summary. The rollout is the only source of truth. Listing and resume read it through the active storage backend, including indexed Redis and SQL storage. A non-empty rollout without a valid header is rejected without changing its bytes; malformed later records are skipped with an operator-visible path, line, byte, and shape warning. Goal cards persist as rollout entries. There is no separate session-state database.
How the pieces relate
- A session contains one or more threads and stores them on disk.
- A thread is a path through the session’s tree of turns.
- A turn is one step on that path.
- The rollout is the append-only log that holds every turn, branch, and system event.
- The goal card is a separate context slot that contains the current objective across turns and compactions. See Goal state and long sessions.
Where the details live
- For session commands and storage, see Sessions.
- For branching, forking, and cloning, see Session branching.
- For plan mode and goal tracking, see Plan mode and goals.
- For how compaction works, see Compaction and project memory.
- For goal state and long sessions, see Goal state and long sessions.
- For contributor-facing internals, see Session and turn internals.
Permission model
Every tool the model attempts to run passes through one gate: the approval mode. The approval mode sets whether a tool runs on its own or waits for you to say yes. You set it once in config, and you can change it for a single run from the command line.
One setting controls this: tools.approvalMode. Nothing else confines what a command
can do once it runs. Veyyon does not wrap commands in an operating-system sandbox
(Landlock, seccomp, Seatbelt, or bubblewrap), so the approval mode is the boundary. Treat
it as the boundary.
Tool tiers
Every tool belongs to one of three tiers, ordered by how much it can change:
- read looks but does not touch:
read,search, and directory listing. - write changes files:
editandwrite. - exec runs commands:
bashand anything else that executes a program.
A mode approves whole tiers, not individual tools. That is why the tiers come first: once you know which tier a tool is in, the mode determines whether it runs.
Modes
A mode is a named choice of which tiers run without asking. There are five:
| Mode | Auto-approves | Prompts for |
|---|---|---|
plan | read | write with an active plan-mode session; write and exec are otherwise denied |
ask | nothing | read, write, exec |
ask-command | read + write | exec |
auto | all tiers | a per-tool policy, the working-directory boundary, credential use, a tool’s own flagged calls |
yolo | all tiers | a blatantly destructive command, and a per-tool deny or prompt |
The schema default is auto. Three older names still work: always-ask maps to ask, and
write and auto-edit both map to ask-command.
Set the mode in config, or override it for one run:
$ veyyon --approval-mode ask-command "run the tests and fix failures"
The launch flags --yolo and --plan-yolo set yolo and a plan-mode variant of it.
The working-directory boundary
A tier describes what kind of thing a tool does. It does not identify which file the
tool is about to touch. In ask-command and auto, the write tier is approved, so
write runs without asking whether the target is src/main.ts or a file in your home
directory.
The working-directory boundary is the second question, asked after the tier:
Does this call touch a path outside the session working directory?
If it does, the call requires approval even though its tier would have allowed it. This
holds in plan, ask, ask-command and auto, so the shipped default is inside it. It
does not hold in yolo, which turns off permission entirely.
Say you launched in ~/projects/api and the model runs this:
$ veyyon --approval-mode ask-command "update the config"
Writing ~/projects/api/config.yml runs without asking, because it is inside the
working directory and write is an approved tier. Writing ~/.ssh/config prompts, because
it is outside, even though the tier is the same.
The check looks at where a path really leads, not at how it is spelled. A path written entirely inside the working directory that reaches outside it through a symlink counts as outside. A path that cannot be resolved at all also counts as outside, because treating an unreadable path as safe is the assumption you least want to be wrong about.
These tools take part: read, write, edit, ast_edit, search,
inspect_image, and set_cwd.
set_cwd is on that list because it changes the working directory itself. If it were
not bound, you could move the boundary instead of obeying it: re-root to the parent
directory, and every later write counts as inside. So re-rooting outward prompts, the same
as writing outward. Re-rooting into a subdirectory does not prompt, because that narrows
what the session can reach rather than widening it.
When no interactive prompt is available, such as a headless or ACP run, a call that needs approval fails instead of proceeding. The error states the path that crossed the boundary, so you can see why the run stopped.
Secrets in arguments
A tier does not tell you whether a call is about to spend a credential either. The secret-use boundary is the third question, asked the same way and in the same modes:
Do this call’s arguments carry a stored secret?
Your secrets reach a tool as real values. The model works with placeholders such as
#GITHUB_TOKEN#, and Veyyon substitutes the credential just before the tool runs, so the
model can use a secret it never reads. That substitution used to be recorded and never
asked about: secrets.auditLog could report afterwards which credential an agent had
spent, and nothing prompted you first.
Now a call whose arguments carry a real credential requires approval in plan, ask,
ask-command and auto, even when its tier would have allowed it. The prompt states the
secret and never shows its value:
Allow tool: bash
Reason: This call uses stored secret: GITHUB_TOKEN. Approving it runs the call with the
real credential.
As with the working-directory boundary, yolo turns permission off entirely and turns
this off with it. Every other rung keeps it, the shipped auto included. A call that
mentions a placeholder without expanding it, such as one made while secrets.enabled is
false, is not a credential reference and does not prompt.
Per-tool overrides
When you want one tool to behave differently from its tier, name it under
tools.approval. Each entry maps a tool to allow, deny, or prompt, and that choice
wins for that tool whatever the mode is, with one exception: while a plan-mode session is
active, a per-tool allow does not let an exec-tier tool run. Plan mode is a cap rather
than a default, so it outranks both the configured mode and the per-tool setting. A deny
is a hard block in every direction.
# ~/.veyyon/profiles/default/agent/config.yml
tools:
approvalMode: ask-command
approval:
bash: prompt
read: allow
Here the mode is ask-command, so writes run without a prompt. The override then pulls bash
back to prompt, so commands still stop for your approval.
Critical bash commands
Within the exec tier, a guard (packages/coding-agent/src/tools/bash-guard.ts) forces a prompt in
plan, ask, ask-command and auto, even over a per-tool allow. It has two halves.
The first half judges what a command would DELETE, and it judges the paths after expansion rather
than the command as text. A tilde and $HOME are resolved, so rm -rf ~/ and rm -rf "$HOME"/
are recognized as the home directory. Every target is judged, not just the first, so
rm -rf tests/ / is caught. Recursive deletes of the home directory, of any directory containing
it, of the system directories, and of the directories that hold your credentials all stop for
approval. So does a recursive delete whose target the guard cannot resolve, such as
rm -rf "$dir"/*: if $dir is empty that command starts at the root, and nothing in the command
text states whether it is.
The same half stops a truncating redirect into a directory that holds credentials, because
echo x > ~/.ssh/id_ed25519 destroys a private key as thoroughly as a delete does. Appending with
>> is left alone, since that is how you add a key to authorized_keys.
Deletes inside your workspace are not affected. rm -rf node_modules, rm -rf dist, and
rm -rf /tmp/build-1234 run without a prompt, and so does a delete inside a protected directory
that does not hold credentials, such as rm -rf ~/.config/some-app. Ordinary redirects, such as
bun test > /tmp/results.txt, are not affected either.
The second half is a pattern list (FLAGGED_BASH_PATTERNS, in the same file) for the shapes that
are about text rather than paths, and each entry is recorded as one of two strengths. Destructive
covers fork bombs, disk destruction, and writes to system credential files. Dangerous covers a
remote fetch piped to a shell, host control commands such as reboot, and a shell wired to a
network socket: they run code nobody read, or take the machine down, without destroying data.
Both halves ship with Veyyon and cannot be narrowed. You can widen the first half with
tools.protectedPaths, a list of absolute paths (a leading ~ is expanded) that a recursive
delete must also stop for:
tools:
protectedPaths:
- /mnt/photos
- ~/Documents
That setting only adds. Nothing in the built-in judgement reads configuration, so no value you
write there can stop the guard refusing your home directory, the system roots, or your credentials.
An entry that is not an absolute or ~-relative path is ignored, because resolving it against a
guessed working directory would protect somewhere other than what you wrote.
The first half and the destructive patterns stop for approval in yolo too, and the /yolo session
bypass does not lift them. That is the one place yolo is not absolute, and it is deliberate:
without it, the commands the guard considers most destructive would be the ones most likely to run
in the mode that skips the check. The dangerous patterns are an ordinary prompt instead: every rung
below yolo stops on them, and yolo does not, because a rung whose whole promise is that it stops
asking cannot be asking about an install command the operator typed. To turn the floor off, set
tools.approval.bash to allow, which is read as a decision you made on purpose. Setting it to
deny remains a hard block.
The guard reasons about what a command will do, and that reasoning can be wrong: a shell function,
an eval, or a script invoked by name defeats any parser. Treat it as a seatbelt, not as
containment.
On deny
When a tool is denied, or a policy check fails, Veyyon returns an error to the model. It does not retry with more permission. An error never escalates what the model is allowed to do.
Related
Model contract
The terminal engine is provider and API agnostic. You choose an endpoint, choose a model when that endpoint exposes model choice, provide the key, and Veyyon calls that API directly. The endpoint can be a local server (Ollama, LM Studio), a direct provider API (OpenAI, Anthropic, Google), or any OpenAI-compatible gateway.
The contract between the harness and the model. For copy-paste provider setup, see Configuring providers. For model switching, see Models and providers.
The three things you bring
A BYOK (bring-your-own-key) run needs three facts:
| Fact | What it is | Where it lives |
|---|---|---|
| Endpoint | Base URL and API kind | A built-in provider, or a custom provider under providers: in ~/.veyyon/profiles/default/agent/models.yml |
| Model | The model id the endpoint understands | Pinned with --model / /model, or discovered from the provider |
| Key | Credential the endpoint accepts | A provider environment variable, /login, or a models.yml apiKey |
For BYOK providers, Veyyon calls the configured endpoint with your credentials (no hosted proxy required).
Optional OpenTelemetry export is separate and only when OTEL_EXPORTER_OTLP_* is set.
Example shape
# ~/.veyyon/profiles/default/agent/models.yml
providers:
deepseek:
baseUrl: https://api.deepseek.com
api: openai-completions
apiKey: DEEPSEEK_API_KEY # env-var name; unset means no key, not a literal
models:
- id: deepseek-chat
name: DeepSeek Chat
contextWindow: 128000
maxTokens: 8192
$ export DEEPSEEK_API_KEY=sk-...
$ veyyon --model deepseek/deepseek-chat
What the harness owns
These behaviors stay constant no matter which endpoint you point at:
- The workflow: read, edit, verify, stop when the work is done.
- Tool dispatch, argument handling, and edit verification through the hashline edit engine
(with
apply_patch/patch/replaceavailable viaedit.mode). - Approval modes (
tools.approvalMode) that gate which tool tiers run without asking. - Context compaction, goal cards, session branching, and rollout persistence.
- Per-model prompt order and tool-form selection once a model (or API kind) is known.
Provider is configuration (endpoint, credentials, model id). Keep the same commands.
What the provider owns
The provider defines the wire protocol, auth scheme, model list, rate limits, and the tokens it returns.
Veyyon adapts to that surface through the provider’s api kind:
- Chat-Completions-style endpoints (
api: openai-completions) talk/chat/completions. - Responses-style and native provider endpoints use their own request shape.
- Model ids come from the provider’s discovery endpoint when discovery runs. There is no hardcoded allowlist for BYOK providers, and discovery returns an error; it does not invent an empty catalog on failure.
Everything beyond the built-in catalog is data in models.yml, see
Providers and docs/handbook/src/reference/providers.md.
System prompts and tool schemas
Each turn the harness builds a request that includes:
- Base instructions for the active model or backend (execution order, stop-when-green, format-neutral tool guidance). See Execution-order prompts.
- User and project instructions from global, active-profile, and project
AGENTS.mdlayers, sticky rules, and session steers. A caller may replace the base for one invocation with--system-prompt. - Tool schemas the model is allowed to call on this turn (bash, edit/write, web search, MCP tools,
skills, and so on), filtered by feature flags, harness-profile allowlists, and plan-mode narrowing.
A per-tool
denypolicy does not filter this list; it rejects the call at dispatch. - Conversation context for the active thread, possibly compacted.
The model is expected to call tools using the schemas it was given. When arguments are almost right but malformed, hashline returns recovery hints so the model can retry inside the same turn budget.
Freeform vs Function tools
Veyyon advertises the structured edit tool in one of two shapes. The payload (the patch or edit body) is the same; only the transport differs.
| Form | How the model calls it | Typical API kind |
|---|---|---|
| Freeform | A custom / grammar tool. The raw body is the tool payload (for example a full *** Begin Patch envelope). | Responses-style |
| Function | A JSON-schema function tool. Arguments are a JSON object (for example {"input": "<envelope>"}). | Chat Completions |
Default edit mode is hashline (edit.mode: hashline). When edit.mode is apply_patch, the
provider wire form is derived from the API kind by default; an optional catalog override can pin the
tool shape to function or freeform. The Function form keeps structured edits available
on chat-wire endpoints (Ollama, LM Studio, DeepSeek, and similar). See The hashline edit engine
for the default edit wire format.
Harness vs provider: a clear split
┌──────────────────────── harness (veyyon) ─────────────────────┐
│ session / turn loop │
│ prompts, tool schemas, edit, approvals, compaction │
└────────────────────────────┬──────────────────────────────────┘
│ HTTPS / local HTTP
▼
┌──────────────────────── provider ─────────────────────────────┐
│ endpoint auth + model discovery + completions/responses │
│ model weights, rate limits, provider-side refusals │
└───────────────────────────────────────────────────────────────┘
If something fails, ask which side is responsible:
- Config rejected at load, malformed
models.yml, missing key → harness / your config. - HTTP 401 / 429 / empty model list → provider or key.
- Patch applied but tests red → harness did its job; the change still needs work.
- Approval or critical-pattern denial → permission model, not the model provider.
Provider data at load time
For BYOK providers, model and provider entries are data in models.yml:
- A YAML or schema error makes the registry skip the custom file with an error message; it does not silently drop models.
- Custom providers are merged alongside the built-in catalog. A custom entry with the same id as an
implicit local engine (
ollama,lm-studio,llama.cpp) replaces that engine’s discovery. - Provider availability requires the id not be in
disabledProvidersand the provider be keyless or have resolvable credentials.
Malformed provider data fails at load. Silent fallback to a weaker provider is treated as a bug.
Per-role models
The conversation model (/model or --model) is separate from background roles. Roles are configured
under modelRoles:
modelRoles.tiny(orsmol): lightweight background work (titles, memory, auto-thinking).
Subagent models are not roles. They live in the Subagents settings area, on two exclusive scopes.
With Same Model for All Subagents off, the first of these names the model: that agent’s row in
subagent.agents, then the agent definition’s own model:, otherwise the default model role.
With it on, subagent.model names it for every agent and the rows above are not read. There is no
silent blend, and a configured value that matches no available model rejects the spawn instead of
quietly handing the decision to the next layer. /agents shows the resolved model and which
setting decided.
See Settings: Subagents and
Models, roles, and profiles.
Automation note
For non-interactive runs, pass the prompt and pick an approval mode that matches your trust
boundary. A headless run has no terminal to answer a prompt on, so a rung that prompts turns the
gated tool call into an error rather than a pause: the default auto runs every tier while the
working-directory, credential and critical-command guards still stop the calls they cover.
$ veyyon --print "run the unit tests and fix failures"
Use --yolo (auto-approve everything) only in trusted automation, ideally in an externally isolated environment (Docker, a VM, a CI jail), since Veyyon does not sandbox the commands it runs.
What stays constant
- Workflow shape: read, edit, verify, stop when done.
- Edit verification, approvals, and context handling are harness behavior.
- Provider is configuration: endpoint, credentials, and model id.
Next
- Configuring providers: Ollama, LM Studio, Anthropic, and custom OpenAI-compatible endpoints.
- Models and providers: choosing and switching models in a session.
- Safety: boundaries around tool use and model output.
- Permission model: the approval modes.
- Signing in: interactive and env-var auth paths.
Features
Features, in two groups: the surfaces of an everyday session, then what extends and customizes the agent. Worked examples are at the end.
Interactive surfaces
These are the parts of the TUI you touch every session:
- Status line and multi-agent UI covers the status segments, the subagent dashboard (
/agents), jobs, and the swarm view. - Keybindings covers the chords.
- The composer gives you prompt history,
@and/completion, andEscto interrupt. See Quickstart and Keybindings. - Web search covers searching from inside a session.
Extend and customize
These add capabilities or change how the agent runs:
| Feature | What it adds |
|---|---|
| Plan mode, goals, and vibe | Engine modes that plan, pursue an objective, or direct workers |
| Skills | Reusable, on-demand instructions |
| Plugins | Packaged extensions |
| Hooks | TypeScript modules that run on events with pi.on(...) |
| MCP | Model Context Protocol servers and tools |
| Branching | Forking a session into parallel lines of work |
| Subagents | Delegating work to background agents |
| Memory | Project-scoped recall across sessions |
| Profiles | Isolated config, sessions, and state per name |
| Personalities | Named voice and behavior presets |
| Speech | Text-to-speech output |
| Export and import | Moving sessions in and out |
| Connectors | Third-party app integrations |
| Approvals | The approval-mode boundary in depth |
| Secrets | Credentials the agent uses by placeholder and never sees |
| Code review | Reviewing branches, commits, and uncommitted work |
| Non-interactive mode | Running Veyyon from a script |
Recipes
For worked examples, see the Task guides. For the full command and setting reference, see Reference.
Editing and repair
Editing reliably is the core of a coding agent, so it is worth understanding how Veyyon does it. The default edit surface is hashline. In practice that means three things work together: numbered lines that come back from read and search, snapshot tags that identify a known state of a file, and the edit tool with its SWAP, DEL, and INS operations.
For the design behind the edit and repair path, see The hashline edit engine.
Failure modes
Models often emit slightly wrong tool JSON, or line anchors that have gone stale. Hashline catches a stale [path#TAG] tag and returns recovery hints instead of writing the wrong bytes. On top of that, general schema repair runs on every tool call before validation. See Repair overview.
Write path versus edit path
Veyyon keeps surgical edits and whole-file writes separate on purpose.
| Path | Applier | Role |
|---|---|---|
edit | @veyyon/hashline (default) | Surgical edits, anchored on snapshot tags and hashline ops |
write | Whole-file writer | Create or overwrite a file, minting new snapshot tags in hashline mode |
apply_patch, patch, replace | Mode-specific parsers | Compatibility modes selected by edit.mode |
There is one hashline edit applier for anchored edits, and write stays separate for whole-file creation. Both honor the same approval policy.
Tools
| Tool | What the model sends | Use it for |
|---|---|---|
edit | A hashline input (default), or a mode-specific payload | Surgical edits |
write | A path plus the full content | New files or full rewrites |
apply_patch | A V4A envelope | When edit.mode is apply_patch |
You set edit.mode to hashline, apply_patch, patch, or replace in config.yml, or use VEYYON_EDIT_VARIANT for a one-shot override.
Hashline workflow
The loop is short:
read(orsearch) returns[relative/path#TAG]andLINE:textrows.- The model calls
edit, anchoring each section on the sameTAG. - On success, the output includes a fresh
[path#NEW_TAG]and a compact diff.
write strips pasted hashline prefixes when appropriate, and can mint new tags after a whole-file write.
Verification after a mutation
After edit, write, or ast_edit changes a path, Veyyon records the mutation.
If the model tries to finish without a later successful bash, eval, debug,
or browser result, Veyyon gives it one targeted continuation turn. The model
must run the check and report what the result established.
A successful command is execution evidence. It does not prove that the command tested the right behavior. You should still read the final verification claim and confirm that it matches the command or browser scenario that ran.
Safety
Edits honor the approval mode, just as bash does. A tools.approval.<tool>: deny policy keeps the tool in the model’s list but rejects every call at dispatch with an error stating the policy. Tools leave the model’s list via <tool>.enabled: false, harness-profile allowlists, tools.discoveryMode (BM25 hiding), extension or agent tool-set overrides, or agent definitions. Plan mode keeps the list and blocks mutations at approval time.
Hashline is the primary write path, and apply_patch is a compatibility mode. There is no single V4A applier that routes every mutation through a make_update_patch envelope.
Approvals
Approvals are how you decide which tools run without asking. One setting drives them:
tools.approvalMode. There is no operating-system sandbox behind it (no Landlock, seccomp,
Seatbelt, or bubblewrap). Shell commands and file writes run as your user, bounded only by
this policy, per-tool tools.approval overrides, and the hard-coded flagged bash patterns
below.
Operator reference. For the model behind it, see Permission model. For the wider boundary, see Safety.
Tool tiers
| Tier | Examples |
|---|---|
| read | read, search, listing |
| write | edit, write |
| exec | bash and other command execution |
Modes
| Mode | read | write | exec |
|---|---|---|---|
plan | auto | ask with an active plan-mode session, denied otherwise | denied |
ask | ask | ask | ask |
ask-command | auto | auto | ask |
auto | auto | auto | auto, with the per-tool, working-directory, credential and flagged-command guards still asking |
yolo | auto | auto | auto, except a blatantly destructive command, which still prompts |
Schema default: auto. Legacy aliases: always-ask → ask, write and auto-edit → ask-command.
$ veyyon --approval-mode ask-command
$ veyyon --yolo # same as --auto-approve → yolo
$ veyyon --plan-yolo # plan now; yolo after leaving plan mode
tools:
approvalMode: ask
The approval prompt
When the active mode requires approval for a tool call, the TUI shows a Permission required card. The card shows the tool, states that the decision applies to this call only, separates the reason from the requested command or file operation, and waits on four options:
- Approve: run this call once. Nothing is remembered.
- Approve for session: run this and every later call to this tool, until you exit.
- Deny: reject this call and return
Tool call denied by user: <name>to the model. - Deny for session: reject this and every later call to this tool, until you exit.
The two “for session” rows are session memory, not policy: nothing is written to
tools.approval, and the next launch prompts again. A remembered decision also covers only
the ordinary tier prompt. The three prompts that are about a call’s ARGUMENTS rather than
its tool name still prompt every time: a flagged bash command, a path outside the working
directory, and a call that spends a stored credential.
The selected option uses a radio marker and includes a short description. Navigate with the usual
list keys (up/down, enter to confirm, esc to cancel; cancelling counts as a denial).
Denied actions return an error to the model, and permissions are never widened.
Headless
veyyon --print has no terminal to prompt in. If the mode would require approval, the tool call
fails with an error that explains the required setting or override (set tools.approvalMode: yolo,
add tools.approval.<name>: allow, or use an interactive UI), and the model receives that error. To
run unattended, pass --yolo or pick a mode that does not prompt for the tiers you need. The
process exit status follows the run.
Critical bash commands
Some shell commands always prompt in plan, ask, ask-command and auto, even over a per-tool
allow override. The guard lives in packages/coding-agent/src/tools/bash-guard.ts and has
two halves.
The first half judges what a command would delete, after expansion rather than as text. It
resolves a leading tilde and $HOME, judges every target rather than only the first, and
stops a recursive delete of the home directory, of anything containing it, of a system
directory, or of a directory holding your credentials. It also stops a recursive delete whose
target it cannot resolve, such as rm -rf "$dir"/*, because an empty $dir makes that
command start at the root. It also stops a truncating redirect into a credentials directory,
such as echo x > ~/.ssh/id_ed25519; appending with >> is left alone. Deletes inside your
workspace, such as rm -rf node_modules or rm -rf dist, run without a prompt, and so do
ordinary redirects such as bun test > /tmp/results.txt.
The second half is a pattern list (FLAGGED_BASH_PATTERNS, same file) for shapes with no
path to expand, and each entry records what it would do. The destructive ones are sudo rm,
recursive chmod/chown on /, fork bombs, disk and filesystem destruction (mkfs, dd to a
device, writes to /dev/sd*), and writes to /etc/passwd/shadow/sudoers. The dangerous
ones are a remote fetch piped to a shell (curl … | sh and its process-substitution and eval
variants), host control (shutdown, reboot, kill -9 1), and network shells (nc -e): these
run code nobody read or restart the machine, without destroying anything.
Neither half can be narrowed; the guard exists because a false negative costs data loss or a
compromised host. You can widen the first half with tools.protectedPaths, a list of absolute
paths (a leading ~ is expanded) that a recursive delete must also stop for. It only adds:
nothing in the built-in judgement reads configuration, so no value there can stop the guard
refusing your home directory. See
the permission model for an example.
The destructive half, and the whole of the first half, stop for approval in yolo as well, and
the /yolo session bypass does not lift them. That floor is the one place yolo is not
absolute. The dangerous half stops on every rung below yolo and not on yolo itself, because
a rung whose entire promise is that it does not prompt cannot be stopping an install the operator
typed. To turn the floor off on yolo, set tools.approval.bash to allow; below yolo an
allow is outranked by the guard, and deny is a hard block on every rung.
Separately, the bash interceptor (bashInterceptor.enabled, default off) blocks shell
commands that duplicate dedicated tools, so the model reaches for read/search
instead of cat/rg/find. Its rules live in bashInterceptor.patterns.
Related
Safety
Commands and file writes go through approval mode (tools.approvalMode). There is no OS command sandbox (Landlock, seccomp, Seatbelt, bubblewrap). Policy details: Approvals. Concepts: Permission model.
Task subagents can use filesystem isolation (CoW worktree backends via subagent.isolation.*) so their edits land in a private tree until merged. That is change-control for subagents, not an OS process sandbox. See the task tool docs, or the Isolation group in the Subagents settings tab.
Operator-visible cases
| Situation | Result |
|---|---|
| Command or edit needs permission | Approval prompt with command/path and cwd |
| Approval denied | Denial / tool failure; no partial escalation of rights |
| Tool JSON malformed but unambiguous | Schema repair, then validation/dispatch |
| Tool JSON ambiguous or unrepairable | Error tool result to the model; no dispatch |
| Tool output truncated | Truncation recorded in the tool result |
| Config / provider data invalid | Load fails with path and context |
Headless
veyyon --print has no TTY for prompts. Set --approval-mode / --yolo explicitly for the job. Reserve full auto-approve for disposable runners. See Non-interactive mode.
Related
Resource limits
Veyyon caps what the processes it runs may consume: CPU, memory, disk writes and process count. Every limit is off by default, and each has two scopes:
| Scope | Key prefix | Covers |
|---|---|---|
| Machine | machine.* | Every veyyon process on this machine at once, including ones already running |
| Session | session.* | One session and the commands it spawns |
machine:
cpuLimitCores: 8 # 0 (the default) is off, at both scopes
memoryLimitGb: 0
writeBudgetGb: 0
maxProcesses: 0
session:
cpuLimitCores: 2 # this session's commands get at most two cores
cpuLimitKill: false # what to do past the budget; see below
memoryLimitGb: 0
writeBudgetGb: 0
maxProcesses: 0
Set both in /settings under Resources, where the two rows for a resource sit side by
side. Machine values are written to the global configuration file, so they hold across every
project, profile and concurrently running veyyon.
A session limit alone is per session: two sessions capped at 2 cores each may use 4 between them. A machine limit is what bounds the pair.
How the two scopes combine
The machine scope is not a second limiter running beside the first. Session groups are created inside the machine group, so the kernel bounds the whole subtree:
<delegated parent>/
└── veyyon.machine/ ← machine.* limits are written here
├── veyyon-<session-a>/ ← session.* limits
└── veyyon-<session-b>/
One set of kernel files enforces both tiers, so they cannot disagree. A machine limit binds a session that sets no limit of its own, and binds a session whose own limit is looser, because the parent bounds its children. The machine group is left in place when a session ends, since another veyyon’s sessions live inside it.
Writes are counted from two places because they arrive from two places: a spawned command
writes through the kernel and appears in io.stat, while the file tools write in-process and
are tallied to a file inside the machine group, so concurrent veyyon processes see each
other’s totals.
What is capped
Every process a session spawns to do its work joins the budget. That covers bash commands (plain
and PTY), MCP stdio servers, the exec calls that custom tools, custom commands, extensions, and
hooks make, background processes from the launch tool, the eval kernels (Python, Ruby, Julia),
language servers, debug adapters, the managed browser, git and jj, ssh, and the installs
that plugins run. A capped process passes the budget to its own children, by cgroup and Job
Object inheritance on Linux and Windows and by a process-tree walk on macOS, so a build that
spawns a compiler fleet is still one budget.
A spawn is refused while the budget is saturated or the group could not be created. That applies
to a bash command, a new MCP stdio server, an exec call from a custom tool, custom command,
extension, or hook, and a new eval cell. An extension module the CLI loads before a session
exists resolves the root session’s gate when it spawns.
Some processes belong to no single session and join the root session’s budget instead. Those are the shared harness workers, such as the tiny title model and embeddings, and the speech capture and playback helpers.
Five kinds of process stay outside the budget. Each is outside for a reason rather than by oversight:
- Anything that starts before a session exists. Host capability probes, the shell environment snapshot, model provider probes, and the ssh bootstrap for a remote auth broker all run when there is no budget to join.
- The harness itself. Agent turns, the TUI, and the relaunch that replaces the veyyon process.
- Programs that are yours rather than the agent’s. The editor veyyon opens a file in, the
clipboard helper,
veyyon shell, and the self-updater. Capping the updater could leave a half-written install, and killing your editor on a budget breach would discard unsaved text. - Threads rather than processes. The browser tab supervisor and the JavaScript eval context run as Bun Workers inside the harness process, and a cgroup holds processes, not threads of one.
- Processes the session did not start. Attaching to a browser that is already running adopts nothing, because the session does not own that process.
If you cap a session at 1 core, veyyon stays responsive while the build under it crawls.
How it is enforced
Each control file below is written on the group for the scope that declares it, so the machine and session tiers use the same mechanism one directory apart:
| Setting | Enforced by |
|---|---|
cpuLimitCores | cpu.max on the group |
memoryLimitGb | memory.max on the group |
maxProcesses | pids.max on the group; the fork itself is refused |
writeBudgetGb | io.stat on the group plus the harness write tally |
Where the operating system offers a per-group CPU quota, the kernel does the capping:
- Linux uses a cgroup v2 directory per session with
cpu.maxset to the core count. A positive budget smaller than one microsecond of the 100ms period writes1 100000, not a freeze quota of0. If the harness’s own cgroup is not writable, veyyon starts a delegated transient service in the systemd user manager (Delegate=yes,CPUQuota) and adopts children into that cgroup. It is not a--scopeunit: a scope would block on the placeholder and leave setup failed. A positiveCPUQuotatoo small for systemd to express floors at0.001%rather than0%. - Windows uses an unnamed Job Object with a hard CPU rate cap.
CpuRateis a fraction of host logical processors (4 cores on a 16-processor machine is 2500, not 40000), counted withGetActiveProcessorCountrather than this process’s affinity mask, so a 2-core budget inside a 2-of-16 slice is 12.5% of the machine rather than 100%. Setting the limit to 0, or/cpu-limit lift, turns rate control off rather than flooring to 0.01% of the machine.
A once-per-second watcher reads the group’s usage on top of the kernel cap. When usage stays
pinned at the budget for about three seconds, new commands are rejected with an error that names
the budget, the measured usage, and the fix (raise session.cpuLimitCores or wait), until usage
drops. The kernel cap is the enforcement of last resort: if the watcher lags, commands throttle,
they never run free.
With session.cpuLimitKill: true, a sustained breach sends SIGTERM to the group’s processes,
then SIGKILL on the next watcher tick if they are still over budget. The kill is reported as a
budget action: the notice and the killed command’s result both state the command was stopped by
the CPU budget, not that it crashed.
macOS has no per-group CPU quota. There the budget is policy only: new commands are rejected
while the group is saturated, running members (including descendants of the adopted child, so a
make -j compiler fleet is in the same set) are reniced, and session.cpuLimitKill still
kills. Nothing throttles. The settings row and the startup warning state this, and the same
warning appears on any platform where no backend works. A configured limit never fails silently:
if the group cannot be created, new commands are refused rather than run uncapped.
Changing session.cpuLimitCores mid-session takes effect on the next command: the live quota is
rewritten, and setting it back to 0 lifts it.
Example
Cap a session to 2 cores and run a parallel build:
# ~/.veyyon/profiles/<profile>/agent/config.yml
session:
cpuLimitCores: 2
$ veyyon
> run make -j16 and watch the load
make spawns sixteen compilers, but the whole tree shares two cores: the build takes roughly
eight times longer than uncapped wall time would suggest, and the rest of the machine stays
idle. While the build runs flat out, another command is rejected:
Refused to start a bash command: this session's CPU budget of 2 core(s) is saturated
(spawned commands used ~2.00 cores for the last 3s). New commands run again once usage
drops below the budget. Fix: wait for the running command to finish, or raise
session.cpuLimitCores.
With session.cpuLimitKill: true, the same breach ends the build instead:
Session CPU budget exceeded: limit 2 core(s), spawned commands used ~2.00 cores for 3s.
Sent SIGTERM to 9 process(es) because session.cpuLimitKill is on. A command that just
stopped was killed by the CPU budget, not a crash.
Reading and lifting limits
/cpu-limit reports; it does not configure. Limits are set in /settings under Resources.
| Command | Effect |
|---|---|
/cpu-limit, /cpu-limit status | Report both scopes, the values in force and what is enforcing them |
/cpu-limit lift | Drop this session’s CPU cap for the rest of the session |
/cpu-limit reset | Drop the session override and return to the configured value |
lift writes nothing to disk: the configured value returns on the next session. It does not
reach a machine limit, which belongs to every session at once.
Related
Secrets
You often need the agent to run a command that requires a credential. A deploy needs a token. A database query needs a password. If you paste the value into chat without protection, it can reach the model provider and any session export you create.
Veyyon can keep the value away from the provider while the command still works. Some local surfaces can still contain it; those are named below.
Turning it on
Secret protection is off by default. The quickest way to turn it on is to store a credential: storing one switches protection on and reports that it did, because a stored credential is only useful once the protection that substitutes it is running.
To turn it on without storing anything, use /settings, or write it into config.yml:
secrets:
enabled: true
The setting takes effect in the current session. Veyyon reloads environment variables, secrets.yml, and the vault when you toggle protection or run a /secret command. Moving to another working directory loads that project’s scope and drops the source project’s mappings.
Your first secret
If the credential is already an environment variable, you have nothing to declare to keep its value out of provider requests. Veyyon treats an environment variable as secret when its value is 8 characters or longer and its name ends with, or has an underscore after, one of KEY, SECRET, TOKEN, PASSWORD, PASS, PASSPHRASE, AUTH, CREDENTIAL, PRIVATE, or OAUTH.
That boundary matters, so read each keyword as a whole word rather than a substring:
| Detected | Not detected |
|---|---|
DEPLOY_TOKEN | TOKENIZER |
API_KEY | SECRETIVE_THING |
KEY_FILE | AUTHORIZED_USER |
GPG_PASSPHRASE | PASSTHROUGH |
APIKEY, PRIVKEY | PWD |
The exclusions are the point of the rule, not a gap in it. Obfuscation replaces every occurrence of a value, so detecting AUTHORIZED_USER would blank out that username wherever it appeared in your transcript. PWD is excluded for the same reason and more sharply: it is your current working directory, it exists in every shell, and detecting it would replace your paths with a placeholder in every message that mentions one.
APIKEY and PRIVKEY need no keyword of their own, because KEY at the end of a name already matches them.
Adding your own keywords
The keyword list is data, not code. Drop a file at either location and its keywords are added to the built-in ones:
| Level | Path |
|---|---|
| Profile | <agent dir>/secret-env-keywords.yml |
| Project | <project>/.veyyon/secret-env-keywords.yml |
keywords:
- VAULTPASS
- SCANSEED
Your keywords follow the same boundary rule, so VAULTPASS matches VAULTPASS and MY_VAULTPASS and not VAULTPASSWORDLESS.
A user file can only add. It cannot remove a built-in keyword, so a repository you clone cannot turn off detection of TOKEN for you. A file that exists but cannot be read or parsed stops startup, because carrying on would cover fewer variables than you wrote down.
If a variable of yours is still not detected, do not assume it is covered: declare it in secrets.yml as shown below, or store it with /secret.
So a shell that already has this:
export DEPLOY_TOKEN=ghp_R2d2c3poIHRva2VuIGV4YW1wbGU
needs no configuration for defensive protection. Start Veyyon and that value is replaced whenever it appears in provider-bound text. Environment detection does not give the agent a readable inventory name. When the agent must choose and spend the credential deliberately, store the same value in the vault:
/secret from-env DEPLOY_TOKEN
That is the form you type in a terminal. Veyyon prompts for a name afterwards and generates one if you skip it. A client with no terminal, such as --print mode or an ACP editor, writes the name on the line as /secret from-env DEPLOY_TOKEN DEPLOY_KEY, because nothing there can prompt; see On a client with no terminal.
What the model sees
Every occurrence of a known value is replaced before provider dispatch. This boundary covers messages, dynamic system prompts, tool descriptions and schemas, resumed assistant text, replay payloads, and nested model calls such as title generation, image analysis, memory summaries, and speech rewriting.
The replacement happens from raw text before trimming, truncation, JSON serialization, or other lossy preparation. Veyyon resolves the live profile, project, environment, and vault runtime again for each physical provider attempt. This includes authentication retries, fallback models, delayed queues, compaction, commit analysis, evaluation, benchmarks, memory services, TTS, and image tools. A refresh cannot leave a retry using an old set of secret values.
Provider fields that are authenticated or signed cannot be rewritten safely. If a live value appears in a signature, provider item id, encrypted reasoning block, or other opaque replay payload, the request is rejected with a value-free error. Structured fields are rewritten recursively, including JSON object keys. A rewrite that would collapse two keys into one is also rejected.
Suppose a file you read contains this token:
DEPLOY_TOKEN=ghp_R2d2c3poIHRva2VuIGV4YW1wbGU
The provider receives a machine-keyed placeholder:
DEPLOY_TOKEN=#0A1B2C3D4E5F678901234567#
The placeholder is stable across restarts on the same machine. It contains a keyed HMAC rather than a load-order index, so seeing it does not give the provider an offline dictionary test for the value. A named vault entry instead uses its readable name, such as #GITHUB_TOKEN#, so the model can choose the right credential.
The model is told two things about a placeholder: that putting one where a credential belongs is expected and works, and that it is opaque otherwise. It does not have the value and cannot request it. For named vault entries it is told one more thing, which credentials it currently has, covered under What the agent knows, and when.
Using a secret in a command
This is the part that makes the feature useful rather than merely defensive. The model can put a placeholder into a command, and veyyon substitutes the real value before the command runs.
The model writes:
curl -H "Authorization: Bearer #0A1B2C3D4E5F678901234567#" https://api.example.com/deploy
The command that actually executes contains the real token. The substitution happens locally, after the model has produced the command and before the shell sees it. The model never learns the value, and the request still authenticates.
The substituted command is not written down. Veyyon records one diagnostic entry per tool call so that a session interrupted mid-call can tell you on resume what was running, and that entry stores the placeholder form, not the substituted one. This matters because /share uploads the session file and backups copy it. What the command prints is a separate question, covered under What this does not protect.
What the agent knows, and when
Store GITHUB_TOKEN today, quit, and start a new session tomorrow. Ask for your open pull requests, and the agent writes #GITHUB_TOKEN# into the curl command without you mentioning the credential again.
It can do that because the system prompt contains an inventory: the placeholders the agent is able to spend at that moment, listed by name and sorted. The inventory is built from the live secret runtime rather than from the conversation, and that is the whole reason it survives a restart. The vault is stored on disk; a conversation is not. Knowledge kept only in the transcript went away with the transcript, while the credential it described stayed exactly where it was.
The inventory holds names, and nothing else. No value appears in it in any state, and the agent has no way to request one. Around the list the agent is told what the list is for: write the placeholder where the credential belongs, the real value is substituted locally just before the tool runs, and a name that is not listed is not available.
Only vault entries are listed, because only they have readable names. A value detected in your environment, or declared in secrets.yml, becomes a machine-keyed placeholder instead, which the agent meets where the value would have appeared rather than in a list.
When protection is off, or when nothing is stored, the section is absent rather than empty. An empty heading reads as “you have no credentials”, and that is a different statement from “this session cannot spend any”. Removing the last credential takes the whole section away again, heading included.
Four moments, and what the agent learns at each:
At session start, or on resume. The inventory, rebuilt from whatever the vault holds right then. Nothing else is needed. A credential you stored last week does not have to be introduced again.
When you add one. The inventory is rebuilt so the new name is in it, and the agent is told directly, in that turn, that the credential now exists and where its placeholder goes.
When you remove or extend one. Both again: the inventory is rebuilt, and the agent is told what changed. A revocation states the placeholder is revoked and must not be used. A fresh lifetime states the credential is still available under the same placeholder. Neither notice quotes a lifetime, because a duration written into the history is wrong a minute later, and your terminal already shows you the exact time left.
When a lifetime runs out on its own. Substitution stops at the deadline itself, not a moment after, and the name leaves the inventory on the next rebuild. There is no notice on this path, because no command ran and so there is no turn to put one in. You are warned twice before it happens, which is covered under Lifetimes.
In none of these does the agent learn a value.
Why a removal is stated rather than left to the list
Dropping the name from the inventory would be the quieter design, and on paper it conveys the same thing. It does not work. Noticing that something has stopped being present in a long prompt is the kind of thing a model reliably fails at, so it goes on writing a placeholder that worked ten minutes ago.
A revoked placeholder cannot reach a tool during the running process. Veyyon remembers the exact name it retired and rejects the call before execution:
Stored secret #STRIPE_TEST_KEY# is no longer available. Store the credential again and update the command.
Text that was never a live credential, such as #TODO#, remains ordinary input. The revocation notice gives the agent the same fact before it tries the call; the refusal is the backstop when the agent keeps using stale history.
For the same reason, the removal notice is delivered even when secret protection is off. The add and extend notices are not: with protection off there is no working placeholder to advertise. A revoked one is different, because it is already sitting in the agent’s history, and the agent needs to hear that it stopped working whatever the setting is.
Turning secret protection off also marks every name advertised in the running process as retired for tool execution. Redaction keeps using the same readable placeholders in provider-bound text, but a stale tool call cannot spend or send them after expansion has been disabled.
The vault: storing a credential with /secret
Environment detection covers credentials that are already in your shell. For anything else, hand it to the vault. A vault entry is encrypted on disk, has a name, and expires.
Storing a credential
In a terminal, everything you type after /secret add is the credential. There is no name to invent first:
/secret add ghp_R2d2c3poIHRva2VuIGV4YW1wbGU
Veyyon takes the value off the line and prompts for what to call it, in a field that shows what you type, because a label is not a secret:
Name this secret (optional). The model spends it by writing #NAME#. Leave empty to have one generated.
>
enter submit esc cancel
Press Enter on the empty field and veyyon generates a name, SECRET_1 and upwards. Type one and it is cleaned up and uppercased for you, so github token becomes GITHUB_TOKEN. Press escape and nothing is stored at all:
Cancelled. Nothing was stored.
That order is deliberate. The credential is what you came to store, so nothing stands between you and storing it, and the name is prompted afterwards where you are free to skip it.
Pasting into a hidden field
/secret add with nothing after it opens a field that shows nothing as you type:
/secret add
Paste the secret value here. You can name it afterwards.
> ••••••••••••••••••••
the value, not a name · hidden as you type, stored encrypted · enter submit esc cancel
Your composer is cleared before the field opens, so the value never enters the input buffer and never reaches your scrollback. Press escape and nothing is stored. Submit an empty field and nothing is stored either, and veyyon reports it rather than storing an empty credential. The name field follows, the same one as above.
Reading it out of the environment
If the credential is already an environment variable, read it from there and type nothing:
/secret from-env GITHUB_PAT
This is the recommended form, because the value never enters the input buffer and never reaches your scrollback. from-env is a command of its own, alongside add, and its first word is the name of the variable. The name field follows here too, so a bare from-env GITHUB_PAT is the whole line you need.
You may write the name on the line instead, and a lifetime and a vault after it:
/secret from-env GITHUB_PAT DEPLOY_KEY 7d project
The variable comes first and the name second, both by position, which is what lets a secret be called PROFILE or NEVER. The lifetime and the vault come after in either order, because no word is both: a vault is one of profile, project and global, and a lifetime is 30m, 12h, 7d, 2w, never, or anything else beginning with a digit. env is the second spelling of from-env.
A value on the command line stays in your scrollback
The one-paste form is on screen until you clear the terminal. Veyyon reports it rather than leaving you to work it out:
The value was typed on screen, so it is in your scrollback. Use /secret from-env next time to avoid that.
Every exact /secret command shape, including malformed input, is excluded from persistent editor history. This prevents the command from being recovered with the Up key or written to history.db. It cannot erase terminal scrollback that was already rendered.
The line is kept byte for byte from its first non-space character to its last, so a passphrase may contain spaces and no part of the value is trimmed away. Use the hidden field or from-env when you would rather the value were never on screen at all.
A command comes first
The first word of a /secret line is a command or it is nothing. The commands are add, from-env, list, rm, clear, rename, value, scope, copy, extend, log, discard and help, plus the second spellings env, remove, delete, wipe, purge, empty, reset, name, replace, move, renew and audit.
A line that begins with anything else is rejected, and nothing is stored:
Unknown /secret command. Nothing was stored. If what followed /secret was a credential, it is now in
your scrollback and was never protected, so rotate it and store the new one with /secret add.
The refusal never repeats the word it rejected, because that word is often the credential itself.
Earlier versions read an unrecognised first word as the credential, so /secret ghp_... stored it. That saved one word and cost three mechanisms: every command had to be reserved in advance so it could not be mistaken for a value, a credential beginning with a reserved word collided with the command, and the collision needed an escape spelling of its own. With the value living behind add there is one place a value is read and none of that is needed. /secret add list of words that is really a passphrase stores that line byte for byte, first word included.
A command stays a command however much follows it, so a malformed one is rejected rather than quietly stored: /secret log 50 is a log with an unreadable argument, not a new secret called SECRET_1.
Every argument is a plain word
There are no options anywhere in /secret. Nothing is spelled with a dash, so there is nothing to look up and nothing to get in the wrong order. A word means something because of where it sits, or because it belongs to a set that cannot be anything else:
| How a word is read | Where |
|---|---|
| its position | rm <name>, rename <name> <new-name>, scope <name> <vault>, extend <name> <lifetime>, from-env <VAR> <name> |
| a set of three words | a vault: profile, project, global |
| a lifetime shape | 30m, 12h, 7d, 2w, never, or any word beginning with a digit |
| a whole number | the record count on log |
Position wins wherever the two could disagree, so a secret really called PROFILE is removed by /secret rm PROFILE and one called NEVER has its lifetime extended by /secret extend NEVER 7d. Where meaning is taken from a word’s shape instead, the sets provably cannot overlap: a secret name may not begin with a digit, so /secret log 50 is fifty records and /secret log GITHUB_TOKEN is one credential’s uses; and a name may not contain a hyphen, so the from-env in /secret value <name> from-env <VAR> is never a name.
The spellings --, --from-env, --ttl, --scope, --limit and --name were the earlier grammar and are rejected, stating the plain word that replaced each one. They are rejected only as the first word after add, so a credential that merely begins with dashes is still stored byte for byte:
/secret add -----BEGIN OPENSSH PRIVATE KEY-----
-- was the worst of them. A slash command has no options to end, so it meant nothing here and had to be looked up, and storing it on the front of a credential produces a secret that expands into requests failing somewhere else entirely.
What you are told when it is stored
Whichever form you used, veyyon confirms with the name it filed the credential under and the placeholder the model will write:
Stored GITHUB_TOKEN in the profile vault, 1d left.
The model sees #GITHUB_TOKEN# and never the value. Write that placeholder where the credential goes.
Storing over a name that already exists is called a replacement rather than a store. That write is how you rotate a credential, and it is also what a fumbled name does, so it is never reported as if nothing had been overwritten:
Replaced GITHUB_TOKEN in the profile vault, 1d left.
The previous value is gone. #GITHUB_TOKEN# now spends the credential you just stored.
The agent is told at once that a credential exists and that it should write #GITHUB_TOKEN# where the value belongs. It is never given the value and cannot request it. It also keeps knowing after this session ends, because the inventory in the system prompt is rebuilt from the vault rather than remembered from the conversation. See What the agent knows, and when.
Managing what you stored
Every verb below works in a terminal and on a client that has none. The value forms above are the only part of /secret that depends on where you are typing.
| Command | What it does |
|---|---|
/secret list | one row per credential: placeholder, scope, time left |
/secret rename <name> <new-name> | relabel it, keeping the value, the creation time and the deadline |
/secret value <name> | replace the value, keeping the name and the deadline |
/secret scope <name> project | move it to another vault |
/secret copy <name> | put #NAME# on the clipboard, never the value |
/secret extend <name> 7d | give it a fresh lifetime, measured from now |
/secret rm <name> [global] | revoke it |
/secret clear profile | remove every credential in one vault, stating what it removed |
/secret clear everywhere | remove every credential in all three vaults |
/secret log [<name>] [50] | which credentials were spent, and where |
/secret discard project | move aside a vault file that cannot be read |
/secret help | every form, on the surface you are on |
value is how you correct a credential. It keeps the name, the scope, the creation time and the expiry, so a token pasted with one character missing does not have to be revoked and stored again: storing it again mints a new name while every prompt in the session still spends the old placeholder, and it re-dates the entry, so a secret with two days left would come back with the default lifetime. The field it opens is hidden as you type, and /secret value <name> from-env <VAR> reads the replacement out of the environment instead.
copy copies the placeholder and only the placeholder. #GITHUB_TOKEN# is the thing you paste into a prompt; copying the value would be the disclosure you stored the credential to avoid.
clear empties one vault. It requires the scope because there is no default: the vault is three files, project overriding profile overriding global, and the copy you can reach is the one that gets spent, so a guess would empty whichever happened to be in front and leave the other two full. It reports the placeholders it dropped. A name that a wider vault still holds is reported as removed but not as revoked, because #NAME# goes on expanding to that copy. wipe, purge, empty and reset are the same command.
clear everywhere empties all three. It reports every scope in one report, including a scope that held nothing, because the question it answers is whether anything is still stored. all, everything and every are the same word. No other verb takes it: “all of them” is not a place to store a secret, not a destination to move one to, and not a vault file to set aside.
scope rejects a move onto a name the destination vault already holds, rather than overwriting it. It moves the time REMAINING rather than the original lifetime, so moving a secret cannot lengthen its life. The copy is written to the destination before the source is removed, so an interrupted move leaves two copies you can see rather than none.
No verb prints a value: not on a row, not truncated onto one, not behind a key. A value put into the vault has stopped being visible, and the surface most likely to end up in a screenshot is the one that must not break that.
Every change reloads the live secret runtime, so a credential you revoke stops being spendable in the session you are sitting in rather than at the next restart. A reload that fails is reported rather than swallowed, because the vault write is already durable and you are the only one who can decide what to do about the gap.
Names are never completed. The dropdown after /secret offers verbs and nothing else. Completing a stored name would put part of your vault on screen on a keystroke, and accepting one would type a name onto a line whose first word decides between a command and a credential. /secret list is where names are read.
Finding what is masked and not stored
/secret list ends with what the session is masking that no name can reach. This is the counterpart to the composer’s N masked chip: both read one counter, so the number below the table and the number above the prompt are the same number.
2 values masked in what is sent, detected rather than declared.
The agent cannot spend them: only a stored secret has a placeholder.
From: /home/dev/project/.veyyon/secrets.yml, DEPLOY_TOKEN.
To stop masking one, unset the variable or narrow the keywords in env-keywords.yml.
Each of these values reached the session through the environment or through secrets.yml, so it has no name and no placeholder the agent can write. What it has is a place it came from: the variable name, or the path of the file that declared it. That label is not a name. It makes the value findable and grants no #NAME# expansion.
One credential exported into the environment and also declared in a file is one masked value with two places to look, and both are named. A value handed in by an SDK caller with no label at all is counted, and the count of those is stated rather than left as the difference between two numbers.
On a client with no terminal
--print mode and an ACP editor have no field that can hide what you type, so they cannot accept a credential you type at all. Every command is the same there, and from-env is the way in:
/secret from-env GITHUB_PAT GITHUB_TOKEN
The name is required on the line here, because nothing on that surface can prompt for it afterwards. add is still listed, with the reason it does not work and the command that does, rather than being hidden and answering only with an error:
/secret from-env <VAR> <name> store the value of an environment variable
/secret add not here: a client cannot hide typing, so use from-env
An inline value is rejected, because that surface keeps its requests in a history you cannot clear:
This client rejects an inline credential, because the line containing it is retained in the client's own
request history. Nothing was stored. Read the value out of the environment instead:
/secret from-env MY_TOKEN <name>.
The refusal repeats neither word after add. Nothing distinguishes a name followed by a credential from a credential whose first word looks like a name, so a message that quoted the part it took for the name would sooner or later quote the credential.
list prints a table:
2 active secrets. The agent spends one by writing its placeholder; the value is never shown.
PLACEHOLDER SCOPE EXPIRES STATUS
#GITHUB_TOKEN# profile 6d left
#PROD_DB_PASSWORD# project 1d left expires soon
Extend one before it lapses: /secret extend <name> 7d.
No part of any value appears there. A prefix of a credential is still a disclosure, and one on screen is one in a screenshot.
The STATUS column and the closing line appear only when at least one entry has crossed a warning threshold, so a table of healthy entries is one column narrower. A cell reads past halfway or expires soon, and those are the same two thresholds that raise the warnings described under Lifetimes. The table and the warnings cannot disagree about which entry is in trouble.
With nothing stored, list reports it and shows the one entry form that surface has, rather than printing an empty table.
Removing and extending each notify the agent of what changed, whichever surface you did it from, so a placeholder you revoked stops being used instead of arriving at a command as literal text. See What the agent knows, and when.
When a vault file cannot be read
Sometimes a vault file survives on disk and stops being readable: a disk filled up mid-write, a backup tool restored half of it, a sync client merged two copies. Veyyon reports which scope, what it means, and what to run:
Your profile vault at /home/you/.veyyon/profiles/work/agent/vault.json exists but could not be
read, so it was skipped and the secrets stored in it are unavailable for the rest of this session:
their placeholders will NOT expand. Every OTHER scope loaded normally, and masking of known secret
values is unaffected. The vault is encrypted, so a hand edit cannot repair it: run /secret discard
profile to move the unreadable file aside. Then store the secrets it held again. The reason it
could not be read was <what the parser complained about>
A vault can also fail in a way Veyyon cannot step around, where nothing in it can be read: the key is gone, the key is not the one the vault was sealed with, or the file is truncated. The session still starts, and reports it:
Your vault could not be read, so this session started WITHOUT it: nothing you have stored is
available, and every #NAME# placeholder it held will be rejected rather than sent as literal text.
Masking of secrets from your environment and secrets.yml is unaffected and still running.
Affected: project (/home/you/work/repo/.veyyon/vault.json). Run /secret discard project to move
the unreadable file aside. Then store the secrets it held again. The reason it could not be read
was <what the parser complained about>
Both notices name one command, because a notice raised by the vault loader cannot determine which client
is about to print it, and discard runs on all of them. It moves the file aside rather than deleting
it: the bytes still hold a real credential under a live key, and a repair that destroyed them would
be worse than the fault it was fixing.
Read the second sentence of the second notice carefully, because it is the part that keeps a broken
vault from becoming a leak. A scope Veyyon could not read is treated as unreadable, never as empty.
#NAME# in a prompt is rejected rather than passed through as the literal text #NAME#, and Veyyon
will not act as though you had stored nothing.
The session starts because the repair is a command inside it. If a vault that would not open also stopped Veyyon from launching, the only way out would be deleting the file by hand, which is the one thing an encrypted store exists to stop you doing casually.
Either route runs the same repair, and prints the same result:
Moved the unreadable profile vault to
/home/you/.veyyon/profiles/work/agent/vault.json.unreadable-1753660800000-8e3a8d58, so that scope
works again. The file still holds your sealed entries, so re-add the secrets it held rather than
assuming they are gone.
The scope keeps working from there. You can store secrets in it again immediately, and the other two scopes were never affected: only the file you named moved.
Your file is moved, not deleted. The name it moved to is in the message because that file is the only route back to what it held. It is still encrypted with a key that is still on disk, so if the damage is a truncated tail, the entries before the damage are still in there. Veyyon will not destroy a credential store to make itself usable again, so the cleanup is yours to do once you are sure you no longer need it.
You have to name the vault. Every other command that takes one defaults to profile, because
there it chooses where to put something and /secret list shows you the result. Here it chooses a
file to move aside, so a default would let a bare /secret discard move a working vault out from
under the session you are sitting in. A bare invocation is rejected and states the word to add.
Two things the repair refuses, both on purpose:
- A scope that reads normally. This is not a second way to delete secrets. Revoke the entry
instead with
/secret rm <name>, which reports what it removed. The check happens at the moment you run the repair rather than from the earlier warning, so a file that was fixed in between is left alone. - A scope that shares its file with another scope. If your profile directory is your config root, the profile and global vaults are one file, and moving it aside as one would take the other with it. The refusal states the other scope so you can decide which you meant.
The repair is reachable from every client, not only the terminal, because a broken vault is most likely to turn up in a headless run.
Lifetimes
Every entry expires. The default is one day, which you can change in /settings under Secret Lifetime.
That setting is the whole answer for /secret add, because the line after add is the credential
and there is no room on it for anything else. To give such an entry a different lifetime, store it,
then run /secret extend <name> 30m. The lifetime you name there is measured from now, not from
when the credential was stored.
from-env takes one on the line, after the variable and the name:
/secret from-env DEPLOY_KEY DEPLOY_TOKEN 30m
/secret from-env SIGNING_KEY SIGNING_TOKEN never project
Lifetimes are written the same way everywhere: 30m, 12h, 7d, 2w, or never. Weeks are accepted and reported back in days. A lifetime and a vault may be given in either order, because no word is both.
You are warned before a lifetime runs out, once at the halfway point and again near the end:
Warning: secrets: #DEPLOY_KEY# expires soon, 2h left. Extend it with
/secret extend DEPLOY_KEY 7d, or it will be deleted.
The remedy is one command, and it runs wherever the warning is read: a notice raised while the vault is loading cannot determine which client is about to print it.
The thresholds are fractions of the lifetime rather than fixed times, so one rule fits every entry. A one-day secret is mentioned after twelve hours; a ninety-day secret is mentioned on day forty-five, not on day eighty-nine. Each warning states the command that prevents expansion from being revoked.
Expiry revokes substitution immediately. If an expired secret merely stopped being obfuscated, its value could flow to the model provider when protection lapsed. Veyyon instead removes the in-memory expansion mapping and keeps a forward-only redaction tombstone.
The deadline is enforced when the credential is used, not only when a session starts. A session left open over a weekend stops substituting a one-day secret on the day it expires and reports both the runtime and persisted state:
Warning: secrets: #GITHUB_TOKEN# has expired and its in-memory expansion has been
revoked. Its encrypted value has not yet been deleted from the vault; a successful
vault refresh will prune it. Store it again with /secret from-env <VAR> if you still
need it, or /secret from-env <VAR> GITHUB_TOKEN in a client with no terminal.
In a terminal the name field that follows is where you type GITHUB_TOKEN to get the same
placeholder back.
The hot-path expiry check does not write to the vault. The encrypted entry remains on disk until the next successful vault refresh prunes it. It remains encrypted and cannot be expanded after the deadline.
If a command still refers to an expired secret, Veyyon rejects it before the tool starts and reports only the retired placeholder. It does not send an empty header or the literal placeholder to a remote service. Old transcript text containing the raw value remains covered by the forward-only redaction tombstone for the life of the same working-directory runtime.
Scope
An entry belongs to one scope and is invisible from the others:
| Scope | Where it lives | Use it for |
|---|---|---|
profile (default) | the active profile’s agent directory | credentials for one line of work |
project | <project>/.veyyon/vault.json, kept out of your commits | credentials for one repository |
global | ~/.veyyon/vault.json | credentials you want everywhere |
A credential you store in a terminal goes to the profile vault. Scope is an option, the argument line there is the credential, so there is no place on it to put one. Profile is the default because that is usually the boundary you want: a credential you use for one kind of work should not be reachable from a session you opened in another profile.
A credential already stored can be moved with /secret scope <name> project. To file one somewhere else as you store it, name the scope on a client that takes a verb and a value on one line:
/secret from-env SCAN_TOKEN SCAN_TOKEN project
When the same name exists in more than one scope, /secret list reports it. The table stays one row
per name, because one row per name is what the agent can spend, and a sentence underneath states the
copies it is not spending:
2 active secrets. The agent spends one by writing its placeholder; the value is never shown.
PLACEHOLDER SCOPE EXPIRES
#SHARED_TOKEN# project 24h left
#SOLO_TOKEN# profile 24h left
#SHARED_TOKEN# is also stored in the global vault, shadowed by the project one. Only the project
copy is spent. Remove it with /secret rm SHARED_TOKEN global.
That copy is inert, not gone. It is still on disk, still decryptable, and it becomes the live one the moment the copy in front of it is removed. Before the list mentioned it, the only way to find out was to remove the copy in effect and read what the removal told you, which is late: you learn about a credential at the moment it starts being spent.
The narrowest copy is the one that wins, and a removal that specifies no vault takes it. Removing that copy uncovers the next one out, and the command reports that too:
/secret rm shared-token
Removed SHARED_TOKEN from the project vault. A profile secret of the same name was underneath it,
so #SHARED_TOKEN# still spends a credential, now that one. Run /secret rm SHARED_TOKEN profile to
remove that one too.
That second sentence is the part that matters, because the placeholder keeps working. Without it you would read a removal, assume the name was dead, and leave a live credential reachable under a name you believe you revoked. The agent is told the same thing, so it does not treat the name as revoked either.
To take a particular copy rather than the one in effect, name its scope:
/secret rm SHARED_TOKEN profile
Naming the scope of a copy that is already shadowed removes it without changing what the placeholder spends, and the command reports that rather than implying something changed.
A vault word belongs to from-env, rm, clear, scope and discard. On a command that does
not read one it is a word that fits no slot, and it is rejected rather than ignored:
/secret extend GITHUB_TOKEN global
/secret extend cannot read the word in position 2, and a word that would be ignored is rejected
rather than dropped silently.
That refusal exists because the alternative is worse than an error. An accepted-and-ignored vault
word on extend reads as “the global copy was given a fresh lifetime” when what actually happened
is that the copy in effect was re-dated and the others were left alone. The same rule covers a
lifetime handed to rm and a number handed to list: a word veyyon would drop is a word you meant
something by.
The refusal states the position and never repeats the word, because the realistic slip is muscle
memory for add under a different command, which puts the credential itself in that position.
Encryption, and what it does not do
Vault files use AES-256-GCM. Each write uses a fresh 12 byte nonce and the full 16 byte authentication tag. The key is a 32 byte file at ~/.veyyon/vault.key, created on first use. It never lives inside a project directory.
On POSIX, the key is mode 0600. Its directory must be owned by you and not writable by another user. On Windows, Veyyon applies and verifies a protected owner-only ACL. Existing vault files receive the same platform permission checks before they are read.
A project-scoped vault lives inside the repository you are working in, so Veyyon keeps it out of your commits. The first time it stores a project secret, it writes .veyyon/.gitignore covering vault.json and the vault.json.unreadable-* file that a discarded vault is renamed to. If that file already exists, Veyyon adds the two rules and leaves your own lines alone. Only the vault is ignored, so anything else you keep in .veyyon/, such as prompt templates, stays trackable. Commit the generated .veyyon/.gitignore along with the rest of your project.
Committing a vault would not expose the credentials directly, because the ciphertext is unusable without the machine key. It would still put a credential store in your history, and nobody who clones the repository can open it, including you on another machine. A vault is not a portable backup. The authenticated location includes the semantic scope, canonical path, and physical scope-directory identity. If you move or recreate that directory, store those entries again.
Updates use a synchronized owner-only temporary file. Kernel no-replace and exchange operations publish the synced inode without overwriting a destination that appeared after the last check. Veyyon holds the scope directory open during the transaction, so replacing the lexical parent cannot redirect the read or write.
Veyyon rejects symlinks, hard-linked files, directories, devices, insecure permissions, and paths whose resolved parent crosses the requested scope. It also rejects ciphertext copied to a different scope or physical directory.
The sealed descriptor is limited to 8 MiB before it is read into memory. Writes enforce a separate 6,291,402-byte encoded plaintext limit before serialization, encryption, or Base64 expansion. A legacy version 1 envelope is rejected because it is not bound to its scope and path. Store those entries again so they use the current authenticated format.
These failures are deliberately loud:
- A vault file present with no readable key stops the session. It is never treated as empty.
- A vault whose nonce, ciphertext, authentication tag, or bound location changed is rejected.
- An unsafe directory, symlink, non-regular path, hard link, or insecure permission is rejected with the path and fix.
What this encryption does not protect against is someone who is already running as you. The key is readable by your own account by design. If you need to defend against a compromised account, use a hardware token or an external secret manager.
Seeing which credential was used where
Hiding a value from the provider bounds what the agent could see. It does not show what the agent did with what it could. The expansion log answers that, and /secret log prints it:
3 most recent use(s), oldest first:
12m ago bash #GITHUB_TOKEN#
{"command":"curl -H 'Authorization: Bearer #GITHUB_TOKEN#' https://api.github.com/user"}
4m ago bash #DEPLOY_KEY#
{"command":"scp -i #DEPLOY_KEY# build.tar deploy@host:/srv"}
just now bash #GITHUB_TOKEN# #DEPLOY_KEY#
{"command":"./release.sh --token #GITHUB_TOKEN# --key #DEPLOY_KEY#"}
One use is when it happened, which tool received it, which placeholders were substituted, and the command as the model wrote it. The last twenty are shown; /secret log 50 requests more.
Narrowing it to one credential
A name answers the question worth asking just before a revoke, which is what stops working:
/secret log GITHUB_TOKEN
Uses of #GITHUB_TOKEN#:
2 most recent use(s), oldest first:
12m ago bash #GITHUB_TOKEN#
{"command":"curl -H 'Authorization: Bearer #GITHUB_TOKEN#' https://api.github.com/user"}
just now bash #GITHUB_TOKEN# #DEPLOY_KEY#
{"command":"./release.sh --token #GITHUB_TOKEN# --key #DEPLOY_KEY#"}
The whole log is read, then narrowed to that credential, and only then cut to the limit. So /secret log GITHUB_TOKEN 20 means the last twenty uses OF that credential, not the last twenty records of which some happened to be it. The two words are told apart by shape rather than by position, and may be given in either order: a limit is a whole number and a secret name may never begin with a digit, so no word is both. The heading states the credential even when nothing follows it, because an empty log for one secret and an empty log altogether support opposite conclusions: the first states this credential has never been spent, the second that nothing has.
An empty log, and a log that is off
An empty log prints the file it is empty at, so it reads as “nothing has happened here” rather than “something failed to load”:
No secret has been used yet. The log is ~/.veyyon/profiles/work/secret-audit.jsonl.
A log that is switched off prints the setting instead:
Secret use is not being recorded, so there is no log to show. Turn on "Record Secret Use" in
/settings (secrets.auditLog) to start recording.
Nothing recorded and nothing being recorded support opposite conclusions about whether a credential was spent, and as an empty list they are the same picture.
The log belongs to the profile rather than to one session, so two veyyon windows in the same profile append to the same file. When the records you are shown come from more than one, the output reports it:
These records come from 2 sessions sharing this profile's log.
Without that line the rows read as one session’s history, and you would count uses another window made.
The recorded command holds the placeholder, not the value, and that is a property of how the record is built rather than a promise about care taken. Veyyon writes the arguments as they were before substitution, which is the form in which every secret is still a placeholder, so there is no redaction step that could be got wrong and no way for a value to reach the file.
Placeholder discovery follows the same recursive string and object-key walk as command expansion. The reader escapes terminal control characters in records, paths, and notices before display. Hard-linked log files are rejected, and generation reads check the 2 MiB bound before allocating a buffer.
Recording is on by default and writes to secret-audit.jsonl in your active profile’s directory. Turn it off under Record Secret Use in /settings:
secrets:
auditLog: false
The file is mode 0600 and lives in the profile rather than the project. If veyyon cannot append to it, it reports it and the command still runs. The value is still protected either way.
At two megabytes, roughly ten thousand uses, the log is atomically moved to secret-audit.jsonl.1 and a fresh one is started. A cross-process lock covers the size check, rotation, append, and read snapshot, so two sessions cannot overwrite a generation or exceed the record cap at the boundary. Oversized rows bound every field and report how many placeholder references were omitted. Both generations are read, so a report requested right after a rotation still fills up.
Declaring secrets yourself
Environment variable detection covers the common case. For anything else, list entries in a secrets.yml file. Two locations are read:
| Level | Path | Use for |
|---|---|---|
| Profile | <agent dir>/secrets.yml | Credentials for one line of work |
| Project | <project>/.veyyon/secrets.yml | Credentials specific to one repository |
Both levels are read and merged. A project entry with the same content as a profile entry replaces it, so a repository can override a declaration without duplicating the rest of the file.
A minimal file protects one literal value:
- type: plain
content: sk-proj-abc123def456
A regex entry protects anything matching a pattern, which is how you cover credentials you have not seen yet:
- type: regex
content: "AKIA[0-9A-Z]{16}"
Patterns always scan globally. You do not need the g flag.
Veyyon rejects regexes that can make no progress, use sticky matching, or contain conservatively detected catastrophic-backtracking forms. Replacement changes only the exact matched span. Equal text outside the regex context is left alone.
The two modes
Each entry chooses what happens to the value.
obfuscate, the default, is reversible. The value becomes a placeholder on the way out and the placeholder becomes the value again on the way back in. Use it when the agent needs to work with the credential.
replace is one way. The value is swapped for a fixed or generated string and nothing restores it. Use it when the agent has no business using the credential at all and you only want it out of the context:
- type: plain
content: hunter2
mode: replace
replacement: "********"
A generated replacement is derived with the machine placeholder key, so it does not expose a cross-machine dictionary oracle. A custom replacement cannot look like a named or machine-keyed placeholder. That restriction prevents one-way text from becoming a request to expand a live credential.
The 8-character minimum
obfuscate mode replaces every occurrence of the value. A three-character secret would blank out fragments of unrelated words, so short values are not obfuscated.
Veyyon rejects them rather than ignoring them. A plain obfuscate entry under 8 characters stops startup with an error stating the entry and the fix. This is deliberate: a session that starts cleanly while sending your declared secret to the provider in plain text is worse than a session that will not start.
The fix is mode: replace, which is one way and has no minimum.
The same floor applies to a credential you store in the vault, and the field that takes the value applies it there. Type something shorter than 8 characters into the hidden field and it is rejected as you leave it, while the value is still in front of you, rather than after you have also named the secret.
A regex match under the floor behaves differently. A short match usually means the pattern reached into ordinary prose, so the match is skipped and the over-matching pattern is reported once. If short matches really are secret, declare it on the entry:
- type: regex
content: "\\b[0-9]{6}\\b"
minLength: 6
A malformed or unreadable secrets.yml also stops startup, and so does a single entry inside it that is not a valid declaration. A mistyped type:, a missing content:, or minLength on a plain entry each name the entry number and the fix:
Refusing to start: 2 entries in /home/you/.veyyon/secrets.yml are not valid secret
declarations, and skipping them would leave the values they declare unprotected.
- entry 0 has type "plaintext", which must be "plain" (an exact value) or "regex" (a pattern).
- entry 3 sets minLength, which applies to regex entries only. A short plain secret needs
"mode: replace", which is one-way and has no minimum.
Every problem in the file is listed at once, so you fix them in one pass rather than finding the next one on each restart. A missing file does not stop startup, because nothing was declared.
Unknown fields are errors too. A misspelled replacement, a plain-only field on a regex entry, duplicate flags, or an option that conflicts with the entry type cannot be silently ignored.
When something cannot be protected
Two kinds of problem can arise, and veyyon treats them differently on purpose.
A problem that would mean a credential reaches the provider stops the session. A declared obfuscate entry under the minimum, a vault file whose key is missing, a secrets.yml that cannot be parsed: each of these is a refusal with the entry and the fix named. A session that starts cleanly while sending your secret out in plain text is worse than one that will not start.
A problem that leaves protection intact but degrades something appears in your session as a warning, prefixed with the subsystem that raised it:
Warning: secrets: pattern matched a 3-character value, under this entry's 8-character floor.
Set "minLength" on the entry if short matches are real secrets, or tighten the pattern.
An over-matching pattern is the usual example. It is discovered while obfuscating a message rather than at startup, so it cannot be a refusal, and it is worth seeing because you cannot otherwise tell a working pattern from one that is quietly reaching into prose.
In --print mode and other non-interactive clients the same warnings go to stderr, so a scripted run does not lose them.
Nothing important goes only to the log file. That was the previous behaviour and it amounted to silence: the log has no console output by default, and nobody opens it.
Where the value goes
| Destination | Sees the real value? |
|---|---|
| Model provider | No, a placeholder |
| Local session transcript | It can. User and tool text is kept locally as written. |
A session you /share | No, a placeholder |
| The vault file on disk | No, encrypted |
| The secret-use log | No, a placeholder |
secrets.yml on disk | Yes, it is a plain file you wrote |
| Your terminal | Only a type: regex pattern you declared in secrets.yml. A vault entry, a detected environment variable, and a plain declaration stay masked on screen. |
| A command the agent runs | Yes, substituted before execution |
The provider boundary is applied again whenever a local transcript is sent. Resuming a session can restore placeholders for display without giving the resumed raw text a path back to the provider.
Changing the working directory is transactional. Veyyon loads the destination runtime before committing the move, and restores both the old directory and old runtime if loading fails. A resumed session or persisted subagent starts from its recorded directory before loading project-scoped secrets.
What this does not protect
Be clear about the boundary.
The two stores differ on disk. Vault entries added with /secret are encrypted. secrets.yml is a plain file: it holds declarations you wrote, in the clear, and anyone who can read it has those credentials. If a value needs to be encrypted at rest, put it in the vault rather than in secrets.yml.
A command the agent runs receives the real value. So a command that prints the credential prints it for real. Its output is obfuscated again before it goes back to the model, but it reached the process, and anything that process wrote elsewhere is outside veyyon’s reach. That output is also saved to the session file as it was printed, so a command that echoes a credential puts it there. The arguments veyyon itself records are redacted, but it cannot redact what a command chose to print.
Protection begins when the value is known. Once you enable protection or store a value, old local transcript text containing that value is sanitized on subsequent provider requests. The local transcript is not rewritten in place.
A value you type on the command line is visible on screen. /secret add <value> puts the credential in your scrollback, and the confirmation reports it. It is excluded from persistent editor history, but the obfuscator cannot scrub a terminal after the fact. Use /secret add on its own, which opens a field that hides what you type, or /secret from-env <VAR>, which types nothing at all.
The secret-use log records use, not intent. It records which credential went into which command. It cannot show what the command did with it once the process had it.
Reference
The field-by-field schema, the merge rules between the two files, and the interaction with environment detection are in docs/handbook/src/architecture/secrets.md.
For provider credentials specifically, veyyon keeps OAuth tokens and API keys in its own credential store rather than in your context. See Signing in.
Models and providers
You choose an endpoint and a model id. Veyyon then calls that provider’s API directly with your credentials. The endpoint can be a local server, a hosted API, or any OpenAI-compatible gateway.
- Contract (what the harness owns vs the provider): Model contract
- Copy-paste provider setups: Configuring providers
- Built-in provider stack internals: Provider stack and BYOK
API keys (BYOK)
BYOK means bring your own key. For a provider you configure yourself, Veyyon sends your key straight to that provider’s endpoint. There is no hosted proxy in between.
Set the key one of three ways:
- The provider’s environment variable (see Providers for the full map), or
/logininside the TUI, which stores the credential in the auth store, or- A
models.ymlapiKeyon a custom provider (an env-var name, orliteral:<text>).
See Signing in for storage modes and Configuring providers
for full models.yml examples.
Minimal BYOK shape
# ~/.veyyon/profiles/default/agent/models.yml
providers:
deepseek:
baseUrl: https://api.deepseek.com
api: openai-completions
apiKey: DEEPSEEK_API_KEY
models:
- id: deepseek-chat
name: DeepSeek Chat
contextWindow: 128000
maxTokens: 8192
$ export DEEPSEEK_API_KEY=sk-...
$ veyyon --model deepseek/deepseek-chat
Built-in providers
Veyyon ships a large built-in catalog (Anthropic, OpenAI, Google, Groq, OpenRouter, Mistral, xAI,
Bedrock, and many hosted gateways) plus three auto-discovered local engines. A provider becomes
selectable when it is not in disabledProviders and it is keyless or has resolvable credentials.
| Provider id | Notes |
|---|---|
anthropic, openai, google, groq, … | Cloud providers; set the env var. Some (for example anthropic) also support /login <id>; see providers. |
amazon-bedrock | Uses the AWS credential chain (AWS_PROFILE, instance role, …). |
ollama, lm-studio, llama.cpp | Local engines, discovered automatically and keyless by default. |
Once a provider is available, model ids come from a bundled static catalog, merged with live
discovery for providers that expose a /models endpoint. Failed discovery returns an error; it does
not invent an empty catalog.
Local models: Ollama and LM Studio
Both are discovered automatically once the engine is running; no models.yml entry and no key are
required.
$ ollama serve
$ ollama pull llama3.2
$ veyyon # then /model and choose an ollama/… entry
$ lms server start
$ veyyon # then /model and choose an lm-studio/… entry
Override the base URL with OLLAMA_BASE_URL / LM_STUDIO_BASE_URL if a daemon listens elsewhere. An
explicit models.yml entry for one of these ids replaces its built-in discovery.
Mid-session model switch
| Action | What it changes | What it does not change |
|---|---|---|
/model (or restart with --model) | The interactive model for subsequent turns | The subagent and compaction models |
Switching the interactive model mid-session never blends through a fallback chain into the subagent or
compaction model. /model shows the current interactive model; /session info shows session stats.
veyyon plugin doctor checks plugin installation health.
$ veyyon --model openai/gpt-5
# later, inside the TUI:
/model deepseek/deepseek-chat
/session info
Model selection
| Piece | Purpose | Config |
|---|---|---|
| Interactive model | Main conversation | /model, --model; persisted as modelRoles.default |
| Roles | Named assignments (smol, slow, plan, advisor, …) | modelRoles / Settings → Model → Roles |
| Subagent policy | Per-agent choices, or one pair for the whole roster | subagent.agents, or subagent.sharedModel with subagent.model and subagent.thinkingLevel |
| Compaction override | Compaction / handoff | compaction.model (else inherit interactive) |
# ~/.veyyon/profiles/default/agent/config.yml
modelRoles:
default: openai/gpt-5 # interactive (persisted default)
smol: openai/gpt-4.1-mini
slow: anthropic/claude-opus-4-5:high
plan: anthropic/claude-sonnet-5
subagent:
model: deepseek/deepseek-chat:high
agents:
reviewer:
enabled: true
thinkingLevel: auto
compaction:
model: openai/gpt-5-mini
Ctrl+P (default binding) cycles roles listed in cycleOrder (schema default smol, slow, not default). Full role list and aliases: Models, roles, and profiles.
Per-model harness settings
Prompt order, repair enablement, and tool exposure can be set per model id through harness profiles and model roles. See Execution-order prompts and Model contract.
Harness profiles
Optional overrides in config.yml or ~/.veyyon/profiles/default/agent/harness-profiles.yml. The two files take different shapes: config.yml nests under harness: (the harness.profiles setting), while harness-profiles.yml reads a top-level profiles: map (a top-level harness: key there is silently dropped).
# config.yml
harness:
profiles:
"openai/gpt-4.1":
repair: true
tools: ["read", "edit", "search", "bash", "write"]
promptSectionOrder: ["tool-policy", "delivery-contract"]
# harness-profiles.yml
profiles:
"openai/gpt-4.1":
repair: true
tools: ["read", "edit", "search", "bash", "write"]
promptSectionOrder: ["tool-policy", "delivery-contract"]
Keys: exact provider/model-id or provider/*. See Per-model repair posture.
Switching providers
Set credentials for the new provider and select a model id from that catalog. Tool surface and tools.approvalMode are independent of provider id.
$ export OPENROUTER_API_KEY=...
$ veyyon --model openrouter/anthropic/claude-sonnet-4
Model selection notes
| Constraint | Typical choice |
|---|---|
| Tool-heavy refactors | Hosted model with tool calling |
| Long sessions / subagents | Choose cheaper models under subagent and compaction.model |
| Low latency | Local or flash-tier cloud |
| Offline / private code | Ollama, LM Studio, llama.cpp |
| CI | Pin exact provider/id with --model |
Pin models in CI and shared profiles (--model, modelRoles). Floating “latest” aliases change under you.
Where to go next
- Configuring providers: full copy-paste setups.
- Model contract: harness vs provider boundary.
- Getting started: first key and first task.
- Configuration: model defaults and overrides.
- Authentication: login, logout, secret storage.
Sessions
A Veyyon session is the unit of interactive work. Start one in the repository you want to modify:
veyyon
The session records turns, tool activity, approvals, edits, and verification output. Long-running work should survive context pressure through explicit goal state, compacted history, working-set facts, and resume metadata rather than relying on the model to remember everything from raw transcript text.
Common session actions
- Start fresh with
veyyon. - Continue saved work from the session picker on launch, or
/resumeinside the TUI. - Branch a previous conversation with
/branch(from a chosen user message) or duplicate the whole session with/fork. - Manage saved sessions with
/session; garbage-collect old artifacts withveyyon gc. - Run a bounded non-interactive task by passing a prompt:
veyyon "…".
Veyyon resumes from the launch picker or /resume, and branches with /branch / /fork.
Long work
For large tasks, make the desired outcome explicit. The harness should preserve active instructions, recent turns, working files, verification facts, and unresolved blockers through compaction. When a session resumes, Veyyon should make the important state visible to the next model turn instead of presenting a clean-looking summary that dropped the real constraint.
Session files are trees
A session file (~/.veyyon/profiles/default/agent/sessions/**/<timestamp>_<id>.jsonl) is an append-oriented log whose entries form a tree. Recorded session entries carry an id and a parentId. Branching appends a new entry whose parentId states an earlier entry, so it starts a sibling branch from that point.
The active leaf advances to each appended entry. On load it falls back to the last entry in the file. Not every line carries parentId: the first-line session header does not, and in-place refresh records are full replacements of the original logical record rather than tree entries. Storage maintenance may atomically rewrite the file to update the header or representation, but it preserves the history entries. Branches you navigate away from remain addressable.
Four properties are guaranteed by the storage layer:
- No history deletion during navigation. Branching appends new entries; abandoned entries remain addressable.
- A corrupt header never initializes over existing bytes. A non-empty file without a valid first session record is rejected. Veyyon leaves it byte-for-byte unchanged so you can inspect or repair it.
- Recoverable record loss is operator-visible. A malformed later record is skipped so one damaged line does not make the entire session unopenable. The session shows one bounded warning with the file, one-based line and byte offset, and shape problem. It never quotes the dropped record’s content. Duplicate ids are last-write-win, and broken parent chains appear as extra roots.
- Moves are transactional. Moving a session changes the transcript, its artifacts, and its recorded working directory as one operation through the active storage backend. If relocation or the final header write fails, Veyyon restores the old paths and in-memory working directory.
Session files written by older Veyyon versions have no linkage fields; they load as a linear chain, which is the exact shape they recorded.
Navigating the tree
Run /tree in the TUI to browse every entry of the session, including branches you previously
abandoned. Picking an entry opens a small action menu:
- Jump here continues from that point. For a user message the jump lands just before it and places the full message text in the composer, ready to edit and resubmit. The start of conversation recalls the original prompt into the composer so you can edit and resubmit it. Anything else (an agent reply, a compaction) branches from that entry with an empty composer.
- Label… attaches a short free-text label to the entry so you can find it again later. Labels
render as
[label]tags in the tree. Submitting empty text, or picking Clear label, removes it.
The tree view filter modes (treeFilterMode in config.yml, also toggled in the /tree UI) are:
| Mode | What it shows |
|---|---|
default | Conversation entries (hides low-signal noise) |
no-tools | default plus hides tool-result-only assistant messages |
user-only | User messages only |
labeled-only | Entries with labels |
all | Every raw entry |
Typing filters rows by preview and label text. There is no separate Conversation/User/Labeled/All tab chrome beyond these filter modes, see Branching.
Forking and branching to a new file
/fork and /branch both create a new session file and never modify the original; /tree
navigation above stays inside the current file.
/forkduplicates the entire current session (every entry, including sibling branches) into a new persisted file. There is no entry picker; for a slice from a chosen point, use/branch.veyyon --fork <session-id>does the same at startup. The launch session picker instead copies only the picked session’s ancestor path (the active lineage) into the new file./branchpicks an earlier user message and copies the history up to that point (or resets to a fresh root if the picked message is the first one) into a new session file, then recalls the message text into the composer for edit-and-resubmit.
There is no /clone slash command in the shipped registry.
Labels are stored in the session file itself as append-only bookkeeping lines (last write wins), so they survive resume and never rewrite history.
Exporting a session
/export renders the current session as a self-contained HTML file you keep, for backup,
inspection, or sharing.
/export: write to the session’s working directory under a generated file name./export <path>: write to<path>.
The command prints the destination and opens the result in your browser. It never modifies the live session.
Programmatic access uses the Agent Client Protocol (veyyon acp) or SDK embedding; no separate daemon
is required. Session tree operations in the TUI use /tree, /branch, and /fork.
Cleaning up old sessions
veyyon gc reclaims disk: it sweeps blobs no session references any more, archives cold sessions, and
checkpoints the database write-ahead logs. It is a dry run unless you pass --apply, and it prints what
it would do either way.
GC never touches a file that was written recently, because a running veyyon may still be appending to it. That window is five minutes by default, and you can change it:
# ~/.veyyon/profiles/default/agent/config.yml
gc:
writeGraceMinutes: 15
Pass --write-grace-minutes to override it for one run. The minimum is one minute: a shorter window
would let GC delete a blob a live session wrote a moment ago, so a smaller value is raised to the minimum
and the run reports it.
Typing while the agent works
Input entered during a running turn goes to one of two places, and the bottom pane always shows which:
- Steer (
Enter). The message is injected into the current turn: the model sees it at the next tool boundary and adjusts course without abandoning its work. - Queue a follow-up (
Ctrl+Q, orCtrl+Enterwhere the terminal delivers it). The message is queued in the running process and starts a new turn once the current one finishes. The queue lives in memory for the lifetime of the process; it is not written to the session file, so it does not survive a restart. Slash commands and!shell escapes queue client-side instead; they are local actions, not model input.
Queued messages render under the composer grouped as Steering·N and After yield·N, with the
dequeue key shown as a hint. They are never delivered after an interrupt: pressing Esc aborts the
turn and pulls every queued follow-up back into the composer so nothing you typed is lost. To edit
a queued follow-up without interrupting, press the dequeue chord (Alt+Up by default, remappable):
the most recent follow-up returns to the composer and older ones stay queued.
Delivery is governed by steeringMode and followUpMode (both one-at-a-time by default; set to
all to deliver every queued message at the next boundary):
# ~/.veyyon/profiles/default/agent/config.yml
steeringMode: all
followUpMode: all
Programmatic clients use the follow_up RPC command to queue a follow-up on an active session.
Recalling a queued follow-up is a TUI-local action (Esc or the dequeue chord); the
RPC protocol has no recall command. An empty follow-up is a no-op in the TUI, and /queue with no
text shows a usage warning.
Next
Read Examples for concrete prompts and workflows.
Subagents
A subagent is a second veyyon session that your session starts, hands one piece of
work to, and collects a report from. The parent spawns it with the task tool; the
subagent has its own context window, so bulk reading and long grinding work stay out
of the conversation you are having.
Everything about them is configured in one place: the Subagents tab in
/settings, backed by the subagent.* settings. /agents is the live picture of a
run in progress: which agents are working right now and what they are saying to each
other. It does not configure anything.
What you get out of the box
One agent type, the general-purpose worker, and delegation that the prompt requests:
subagent:
delegation: preferred # the default; the prompt requests that substantial work be delegated
Each subagent runs the model and effort set on its own page in the roster. An agent that names neither runs the profile’s default model at medium effort. Changing the model you are talking to moves that session and nothing else.
Veyyon also ships five specialists (scout, reviewer, designer, librarian,
sonic), and they are disabled by default. During first-run setup, the
Choose subagents step shows every available role with only task checked.
Enable the specialists you want the model to start on its own. Each enabled type
adds its description to future requests, so leave roles off when you do not use
them.
How hard to push
subagent.delegation sets how hard this session is pushed to delegate:
| Value | What happens |
|---|---|
allowed | The tool is there; the model judges when it helps, and the prompt does not request it. |
preferred | The default. The prompt instructs the model to fan substantial work out instead of doing it alone. |
required | The same, plus a first-turn reminder that delegation is the default here. |
The strength applies only when an enabled role matches the work. If task is
enabled, it acts as the general-purpose fallback. If only specialists are
enabled, work that matches none of their descriptions stays in the main
session. With no enabled agent, the delegation preamble is omitted.
The separate subagent.enabled boolean (default on) is the kill switch: off removes the task
tool and every delegation instruction from the prompt, so nothing can be spawned. A legacy
delegation: off migrates to subagent.enabled: false.
The instructions follow the exact roles you enable. With only task offered,
the prompt uses it as the general-purpose route. Enabling designer or
reviewer adds those separate roles without changing what task means.
What counts as delegable work
subagent.delegation sets how strongly the model is pushed to delegate.
The description of each enabled agent scopes the work that role covers.
These are separate settings.
Veyyon preserves concrete roles. It does not infer a second role category from
the tools an agent can call. For example, designer remains a designer and
reviewer remains a reviewer. The model chooses the closest matching
specialist for each independent slice:
# ~/.veyyon/subagents/accessibility-reviewer.md frontmatter
name: accessibility-reviewer
description: Reviews terminal interfaces for accessibility problems and reports findings
tools: read, search
Enable that role when you want it available:
$ veyyon config set subagent.agents.accessibility-reviewer.enabled true
When task is enabled, the model can use it for substantial work that does not
fit a specialist. When task is disabled, the model keeps unmatched work
inline. This prevents a specialist name from becoming a generic worker merely
because no closer role is available.
An agent role is routing guidance, not a security boundary. Use the sandbox when you need to restrict filesystem or process access.
Choosing agents
subagent.agents holds one row per agent name. You choose initial permissions in
the first-run Choose subagents step, then edit them through /settings →
Subagents → Roster. The roster lists every discovered agent with its state,
resolved model, and deciding setting. Enter opens one agent to set its state,
model, and effort, or reset it to defaults.
To add an agent, put a markdown definition in ~/.veyyon/subagents/, or start
from the shipped definitions by running veyyon agents unpack. The definition
makes the role available. Enable its row before the model may start it.
Writing a subagent covers the frontmatter fields, the
system prompt and enabling the result.
That directory is read by every profile, and the file is the whole definition.
Which profile may spawn the agent is a separate, per-profile answer:
subagent.agents.<name>.enabled. Write the agent once, enable it where you want
it.
A definition that lists a tool veyyon does not recognize is reported at startup rather than ignored. The tool grants nothing, and the guidance for it is left out of the agent’s system prompt, so a typo used to read as an agent that simply chose to do nothing.
A row has two states:
| State | Meaning |
|---|---|
| Enabled | Listed in the task tool and choosable by the model. Only the bundled task worker defaults to enabled. |
| Disabled | Refused even when named, with a message pointing at the setting. Specialists and user or project agents default to disabled. |
The built-in flows still work with the specialists disabled because a command can grant its
agent for the turn: /review requests agent: "reviewer" through a per-turn grant, and so can
you (“use the scout agent to map the parser”).
Writing an agent file makes the role available but does not grant spawn permission. Enable the role during setup or in the Agents settings table.
Choosing models
Two scopes choose a subagent’s model and effort, and Subagents → Same Model for All Agents selects which one is in force. They are exclusive, not layered: the rows of the scope that is off are not drawn.
Off, the default, each agent decides. Open Subagents → Roster, press Enter on an agent, and set the model and the effort on that agent’s own page. The first of these that names a model wins:
- that agent’s lane,
subagent.agents.<name>.model, and for a nested spawn thesubagentslevel under it that governs that depth - the agent definition’s own
model:frontmatter, for an agent you wrote - the profile’s
defaultmodel role
subagent:
agents:
reviewer:
enabled: true
model: anthropic/claude-opus-4-5
thinkingLevel: high
Effort resolves on the same three layers, ending at medium. An explicit :effort
suffix on the resolved model pattern outranks all of them.
On, one pair decides for the whole roster, and the per-agent Model and Effort rows are
hidden. Shared Model (subagent.model) and Shared Effort
(subagent.thinkingLevel) sit under the switch; an unset chain runs every agent on the
default model role:
subagent:
sharedModel: true
model: openai/gpt-5
thinkingLevel: high
A lane keeps whatever it holds while the switch is on, and decides again the moment the switch goes off.
The default model role is the model the main assistant starts on, and it is the
one keystroke path for the common case: /model writes it, and every agent with no
model of its own follows it. A temporary pick, role cycling and plan mode move the
live session model only, so an agent never changes model because of a keystroke
aimed at the main assistant.
subagent.modelByDepth bound a chain to a spawn depth rather than to an agent and no
longer applies. A config still holding it is reported once, naming the roster page that
replaces it.
Fallback models
Every one of those places takes a list, not just one model:
subagent:
agents:
reviewer:
model: anthropic/claude-opus-4-5,openai/gpt-5
The first entry is what that agent runs on. The rest are held in reserve: when a run errors on the model in use, that agent retries on the next entry rather than failing. The settings picker writes the value for you: open the model row, add a fallback, and press Enter on any entry to move it up the list.
A longer chain reads better as a list, and both spellings mean the same thing:
subagent:
agents:
reviewer:
model:
- anthropic/claude-opus-4-5
- openai/gpt-5
Write it whichever way suits the file. compaction.model takes a chain the same two ways.
A chain only covers errors at run time. A model pattern that matches nothing is still a configuration mistake, so veyyon will not spawn the agent and states the setting, rather than quietly running it on the next entry: a typo must not silently downgrade the agent you spawn.
In the Subagents block above the composer, an agent that fell back is marked with ↓ before its
model badge, so you can tell a deliberate model from a retried one at a glance.
Effort is chosen from a list: off, minimal through max, auto, or Inherit.
Inherit on an agent’s own page means the default effort; on a nested page it means the
page above it. auto requests that the provider choose. The same list appears in both
places, so you cannot set a level that does not exist. If a hand-written config
holds one that does not, veyyon reports the levels that work, rather than
treating it as Inherit and leaving you with a setting that reads as configured and
changes nothing.
A configured model that matches nothing available does not quietly fall through to the next layer. The spawn is rejected and the message states the setting to fix, because falling through is indistinguishable from your setting having no effect. Both agent surfaces show, for the selected agent, the pattern, the model it resolves to, and which of the three layers decided.
Watching a run
While a spawn is in flight, the Subagents block sits above the composer with one
lane per agent. A lane reads left to right: a rail, the agent’s id, what it is doing,
and the model it resolved to.
Subagents
▏ DockerSecretHarness bash cargo test --workspace --all-targets claude-opus-5 high
▏ SecretModeFlowUX read modes/interactive-mode.ts claude-opus-5 high
▏ SecretModularityAudit Audit secrets subsystem modularity, wiring, and… claude-opus-5 med
▏ RateLimitedWorker Retrying (2/5) in 38s · 429 rate limit exceeded claude-opus-5 high
The id is painted in that agent’s own accent, the same hue the status line gives its name and the same one a delegated todo row uses to point back at it.
The middle column holds the most urgent fact the agent has. An agent asleep between provider attempts shows the recovery, its attempt count and the reason, counting down. An agent running a tool shows the tool and its argument. An agent waiting on the model has nothing to report, so it shows the work it was given instead, dimmed. Every lower rank is still true when a higher one is, and a lane that printed the description while the agent was asleep on a rate limit was byte-identical to one thinking.
Light travels down the rail while agents are working, and a lane is lit only while it
has a tool in flight. The head crosses the whole block, so the cycle belongs to the
block rather than the row, and arrives cold on a lane that is waiting or recovering.
Where display.transitions is off, the block is still.
There is no elapsed clock and no context gauge. Total age ranks agents by seniority,
which nothing acts on, and a parent decides nothing with a subagent’s remaining
window. Whether a lane is stuck is answered by the recovery column. /agents carries
the roster with the numbers.
A lane keeps its badge on its own row. Narrow the terminal and the model badge comes off first, then the columns shrink to what is left. Nothing wraps: the block draws no row it cannot fit, and draws nothing at all rather than overflow.
Eight lanes are drawn. Past that the block states how many more are running and points
at /agents, which is the full roster. That row is the only place a count appears; the
header is bare.
Limits and isolation
The remaining groups in the tab are operational: how many subagents run at once
(subagent.maxConcurrency), how deeply they may nest (subagent.maxNestedSpawnDepth),
per-run wall clock and request budgets, how long a finished subagent stays live
before parking (subagent.idleTtlMs) and how long it stays listed after that
(subagent.prune.*), and whether its edits land in an isolated
copy of the tree first (subagent.isolation.*, see Safety).
Park and prune are two stages, and they do different things.
Park releases the live session, the process, its MCP clients and its memory. The
roster row and the transcript stay, and messaging or opening the agent revives it.
subagent.idleTtlMs (“Park After”) is the budget, five minutes by default for every
model and provider. Set a positive millisecond value to override it, or 0 to keep
idle agents live until exit.
Prune drops the roster row and with it the ability to wake the agent, so a long
session does not accumulate every agent it ever spawned. It deletes nothing: the
transcript stays readable at history://<name>.
Three settings in the Prune group control stage two. subagent.prune.enabled is on by
default; turn it off to keep every parked subagent listed and revivable until you exit.
subagent.prune.afterMs (“Prune After”) is how long a parked subagent keeps its row,
counted from the moment it parked, and defaults to one hour.
subagent.prune.waitingAfterMs (“Prune After While Waiting”) is the same budget for a
subagent whose last message reported waiting on another agent, and defaults to two hours:
it stopped on purpose to let a peer finish, so it is the agent you are most likely to
message next. Set the two equal to treat both the same.
Turning pruning off does not turn parking off. Parking is what releases the session, and it happens either way; the prune switch only sets whether the parked row is eventually dropped. “Park After” sits in its own Park group for that reason, and the prune switch never hides it.
A subagent read back from a previous run is judged on the same budgets. Its age comes from when its transcript was last written, not from when this session found it, so resuming a session does not reset every old agent’s clock to zero.
When each budget starts counting
Both budgets count from the agent’s last transition, not from when it was spawned. An idle agent’s park budget starts when it went idle. A parked agent’s prune budget starts at the moment it parked. A revived agent starts its park budget again from the revival, so messaging a parked agent gives it a fresh five minutes rather than resuming a clock that was already half spent.
That is why a long-lived session does not prune everything at once: each agent’s deadline moves with its own activity.
Only idle and parked agents have a deadline
A running agent has no deadline at all, and neither does an aborted one. Nothing
parks or prunes an agent that is mid-turn.
This matters when an agent looks stuck. A subagent waiting for you to answer an approval
prompt is still mid-turn, so it stays running and no park or prune timer applies to it.
If a finished agent is not being cleaned up, check its status first: the lifecycle only
acts on idle and parked, so an agent stuck in running is a different problem and the
prune settings will not affect it.
Turning it off
Set subagent.prune.afterMs to 0 and no parked agent is ever pruned. That also
forces the waiting budget to 0, whatever subagent.prune.waitingAfterMs is.
That coupling is deliberate. If a zero parked budget still honoured a separate waiting budget, the only agents that were ever pruned would be the ones that stopped to wait on a peer, which are the agents you are most likely to message next. Zero means never prune, for both kinds.
If the session cannot be saved
Parking flushes the agent’s session to disk before releasing it. If that flush fails, the park is cancelled and the agent stays live with its timer re-armed. You keep a live agent rather than losing unsaved state, and the attempt repeats on the next expiry.
Nesting depth
subagent.maxNestedSpawnDepth is inclusive. The default is 0: the top-level session,
at depth 0, may spawn direct subagents, but those children are leaves and cannot spawn
more subagents. A value of 1 also lets direct children spawn, producing children at
depth 2. Higher values extend the same rule, and -1 allows nesting without a depth
limit.
An agent-specific value takes precedence over the blanket value:
subagent:
maxNestedSpawnDepth: 0
agents:
reviewer:
maxNestedSpawnDepth: 1
Here ordinary direct subagents remain leaves. A direct reviewer may spawn its own
children because its effective limit is 1.
A subagent’s working directory is its own. If a subagent calls set_cwd, only that
subagent moves: its tool paths resolve against the new directory and its system prompt
is rebuilt for it, while your session and every other subagent stay where they were.
That matters because subagents run inside the same process you do. The main session also moves the process working directory when it re-roots, so that a command you run and a relative path you write agree with the project you have open. A subagent doing the same would move the ground under everyone else, and the symptom would be a command running in the wrong repository with nothing on screen to explain it.
The trade is that a subagent working elsewhere does not pick up that project’s settings, capabilities or plugins, because those are read once for the process. Give a subagent a task in another project only when the work is self-contained, and re-root your own session instead when you want that project’s configuration to apply.
The full key list is in the settings reference.
Writing a subagent
A subagent is one markdown file: YAML frontmatter that states its name, what it is
for and what it may use, and a body that is its system prompt. Veyyon reads the file
at startup, the roster lists it beside the bundled agents, and the task tool can
spawn it once its row is enabled.
Where the file goes
$ mkdir -p ~/.veyyon/subagents
$ $EDITOR ~/.veyyon/subagents/accessibility-reviewer.md
~/.veyyon/subagents/*.md is the only place veyyon reads user-authored agents from.
Every profile reads that directory, so an agent is written once. Whether a profile
may spawn it is a separate answer, subagent.agents.<name>.enabled, stored per
profile.
There is no project-level directory. A definition supplied by a repository could shadow a bundled agent by name, and first-run setup would then offer that role as an ordinary row.
Extensions ship agents in their own agents/ directory, which veyyon discovers from
the extension roots. That spelling is the plugin-author contract and is not a path
to type by hand.
The shape of the file
---
name: accessibility-reviewer
description: Reviews terminal interfaces for accessibility problems and reports findings.
tools: read, search, bash
model: anthropic/claude-opus-4-5
thinkingLevel: high
---
You review terminal interfaces for accessibility problems.
Report each finding with the file, the line and the input that reproduces it.
Report nothing you have not reproduced.
name and description are required; a file missing either is skipped. Everything
else is optional.
| Field | Effect |
|---|---|
name | The name the model spawns, and the roster row. Match the filename to keep the two findable together. |
description | What the role covers. This is the text the model routes on, so state the work, not the job title. |
tools | The tools this agent may call, as a list or a comma-separated string. Omitted grants the default set. yield is added if you list any tools at all. |
spawns | Which agents this one may spawn: a list, or * for any. Omitted means none. |
model | The model this agent runs when its roster row names none. A list is a fallback chain. |
thinkingLevel | The effort it runs at when its roster row names none: off, minimal, low, medium, high, max, auto, or inherit. thinking is accepted as a synonym. |
blocking | true runs the agent to completion before the parent continues. |
autoloadSkills | Skills loaded into the agent’s context at spawn. |
readSummarize | false makes its read tool return verbatim file content instead of structural summaries. |
output | A JSON schema the agent’s yield payload is validated against. |
A two-word key is read in either spelling: thinkingLevel and thinking-level reach
the same field, and so do autoloadSkills and readSummarize. An underscore does
not, so thinking_level is ignored. The bundled definitions use the dashed form, so
an unpacked agent reads thinking-level: medium where the table above says
thinkingLevel.
A name in tools that matches no built-in tool and carries no mcp__ or extension
namespace is reported at startup. The tool grants nothing and its guidance is left
out of the system prompt, so a typo reads as an agent that chose to do nothing.
The body is the system prompt
Everything after the frontmatter is the agent’s system prompt, rendered with Handlebars. Write it as instructions to the worker: what it owns, what it must not touch, what it returns. The worker sees this instead of the main assistant’s prompt, not in addition to it, so state the constraints that matter for the lane.
Enable it
Discovery makes the role available. Spawning it needs the row enabled in the profile you want it in:
$ veyyon config set subagent.agents.accessibility-reviewer.enabled true
Or open /settings → Subagents → Roster, select the agent, and set its state
there. The same page sets that agent’s model and effort, which outrank the model:
and thinkingLevel: in the file.
Start from a bundled agent
veyyon agents unpack writes the shipped definitions to ~/.veyyon/subagents/ as
ordinary markdown files, frontmatter and all. Copy one under a new name and edit it.
$ veyyon agents unpack --dir ./unpacked-agents
--dir writes them somewhere else, which keeps the unedited copies out of the
directory discovery reads.
A definition that keeps a bundled agent’s name replaces that agent: a file in
~/.veyyon/subagents/ outranks the bundled definition of the same name, so writing
reviewer.md there means your reviewer is the reviewer.
Check the result
Open /settings → Subagents → Roster. The agent is listed there with its state,
the model it resolves to, and which setting decided. An agent that is discovered but
not enabled is listed and refused when named, with the setting to change.
A file that is skipped says why on startup: a missing name or description, a
file that cannot be read, or a tool name that matches nothing.
Multi-agent monitoring
The interactive TUI is the main surface for one session. Status line, session tree, background jobs, the subagent dashboard, and optional swarm orchestration cover multi-agent work.
Status line
Configure under Settings → Appearance → Status Line (/statusline jumps to this group), or in config.yml:
| Key | Purpose |
|---|---|
statusLine.preset | default, minimal, compact, full, nerd, ascii, or custom |
statusLine.leftSegments / statusLine.rightSegments | Segment lists when preset: custom |
statusLine.enabled | Show the footline at all (on by default) |
statusLine.showAccount | Name the account serving the next request, when the provider stores more than one (off by default) |
statusLine.sessionAccent | Tint the editor border with the session color |
statusLine.showHookStatus | Show active hook status when hooks run |
Built-in segment IDs include: pi (legacy product mark segment), profile, model, account, mode, path, git, pr, subagents, token_in, token_out, token_total, token_rate, cost, context_pct, context_total, time_spent, time, session, hostname, cache_read, cache_write, cache_hit, session_name, usage, collab.
The model segment shows the model you are working with, then two things that are easy to confuse, so they are drawn differently:
- The thinking effort joins the model label as one unit (
Sonnet 4.5 @highin the quiet footline,Sonnet 4.5 · highelsewhere), in the model’s own color. It is how much reasoning the model does per turn. Change it with/effort(its alias is/thinking), or set a per-model default under Settings → Model → Default Effort. The effort picker has a Default row followed by only the active model’s valid variants. Choose Default to clear the session override and return to the saved per-model effort or the model default. - The priority tier follows the effort as its own chip, named and in the warning color (
⚡ priority, or justprioritywhen your symbol preset has no icon). It is how your requests are queued and served, not how deeply the model thinks. Toggle it with/fast, or set it per provider family under Settings → Model → Service Tier.
The git segment shows the current branch, and appends the multi-step operation you are part-way through when there is one:
main on a branch, nothing in progress
detached detached HEAD
topic|REBASE rebasing topic
main|MERGE a merge that stopped on a conflict
main|CHERRY-PICK a cherry-pick that stopped on a conflict
main|REVERT a revert that stopped on a conflict
main|AM applying a patch series with git am
detached|BISECT bisecting
A rebase detaches HEAD, so without the suffix the segment could only say detached, which shows neither the branch being rebased nor that a rebase is running. Veyyon reads the branch back from the rebase’s own record, so you see topic|REBASE. A merge is the opposite case: it leaves HEAD on its branch, so the segment would look like an ordinary checkout while your working tree holds conflict markers. The suffix is what distinguishes them.
The pr segment is skipped while any of these operations is in progress. A branch being rebased does not yet point where it is going to end up, so a pull request looked up against it would describe a state that is about to be replaced.
The profile segment shows the active profile name (work, rec, a client sandbox), so you always know which profile’s config, sessions, and keys are live. It hides itself on the built-in default profile, so an unconfigured status line stays clean. Every built-in preset places it, so switching profiles is visible without any configuration.
The path segment shows the working directory, shortened in three steps: a workspace root is
stripped off the front, the home directory collapses to ~, and what is left is clipped to a
column budget from its front, so the directory you are in stays readable. Each step is a
statusLine.segmentOptions.path key:
statusLine:
preset: custom
segmentOptions:
path:
displayRoots: ["~/code", "/srv/workspaces"] # workspace roots, most specific first
maxLength: 40 # columns the path may spend
abbreviate: true # collapse the home directory to ~
stripWorkPrefix: true # strip a workspace root at all
displayRoots lists the directories your projects live under, and ~ stands for the home
directory. A working directory under one of them is shown relative to it, so
/srv/workspaces/platform-services/normalizer reads platform-services/normalizer. The first
entry that matches wins, which is how a nested root is named ahead of the one above it. The list
replaces the defaults rather than adding to them; without it the defaults are ~/Projects and
/work/. An entry that is not an absolute path cannot contain a directory, so it is dropped and
named in the log once. stripWorkPrefix: false turns the whole step off.
A project inside a temporary directory is shown relative to that temporary directory instead,
with its own icon, whatever displayRoots says.
The launch card draws the whole row before the session mounts. The working directory comes from the
process, the branch from .git/HEAD and its ref files, and the mode from config. The model name,
the effort beside it, the dirty marker (*) and the context gauge are what a previous launch
recorded, in cache/launch-facts.json.
That file records three kinds of fact. The model’s display name, its provider, the effort and the
at-rest context reading belong to the model, so they are stated in every project you use it in,
including one you open for the first time. The dirty marker belongs to the project, because it
describes that working tree. The gauge is recorded under both, as two different numbers: the
project keeps the whole reading measured in that directory, and the model keeps the same reading
with that project’s context files subtracted. A project that has never been measured states the
model’s number, so it starts from a cost every project shares rather than from the last
directory’s AGENTS.md. Those two maps are keyed by the release as well.
The third fact is the terminal’s background color, keyed by the terminal and by nothing else. A new release does not change what an emulator draws, and it is the same window whichever directory is open in it. Veyyon queries the background at startup and the reply arrives after the card is on screen, so the card mixes the composer hairline, the composer outline and the transcript rules out of the background this terminal reported last time. A reply that contradicts the record takes effect on the next frame. Each of the three maps holds its 24 most recently written entries.
Each recorded fact is replaced by a measured one as the session mounts. A tree committed from
another terminal since the last launch keeps the recorded marker until git status answers, about
130ms in. A project you open for the first time has no dirty marker. The gauge reads ? only
until this model has idled somewhere once; after that a new project starts at the model’s
subtracted reading and the session adds what this project’s own context costs, so the bar fills
rather than empties when the session lands. Configuring a different model resets it to ? again,
because a reading is a fraction of the window it was taken against. A model you have never run
states its configured id’s last path segment until the catalog supplies a display name.
A repository whose refs are in a reftable has no ref files to read, so its branch appears with the session rather than with the card, as does a detached HEAD with no operation to name.
/secret list states what is live where you are working: the credentials the vault holds here, and
a count of the values being masked that nothing can name, with the environment variable or
secrets.yml path each came from. The status row does not carry a count. It reported one that
nothing else in the product agreed with, and a number a reader cannot reconcile with /secret list
is worse than no number.
The context_pct segment answers one question: how much room is left before the context runs out. “Runs out” means whichever comes first, auto-compaction firing or the model’s window filling, so with auto-compaction on the segment measures against the compaction trigger, not the window. The window itself is what context_total prints.
In the composer’s quiet footline the segment renders as an 8-cell bar with a labelled percentage: ▰▰▰▰▰▰▱▱ 76% left ∞. The bar drains, one cell per eighth of the room remaining, so the bar and the number always agree. Filled cells take the usage hue (silver, then gold, ember, and red as room runs out). While the model is running, the last remaining cell pulses between filled and empty, faster once you are past 90 percent used; at rest the bar never moves, so an idle screen is still. A session-accent ∞ after the bar means auto-compaction is on, so the session continues past the trigger.
Classic status-line presets render the same measurement as text, in tokens on both sides of the slash: 47K/170K. For a full picture of what is in the window, and how it is divided between the system prompt, tools, skills, and messages, run /context.
Two run clocks tick alongside the segments, both measuring model runtime, never idle wall time. While the agent runs, the location line (path and git branch) ends with the current run’s elapsed, in M:SS form and widening to H:MM:SS past the hour: …keyhog · main * 12:34. When the run finishes the clock freezes as ✓ 12:34 (checkmark plus the final M:SS/H:MM:SS readout); before the model has ever started it shows nothing. The working line shows how long the current step has been running, between the step label and the esc hint: Running tests · 0:42 ⟦esc⟧; that clock restarts whenever the step changes. The time_spent segment is related but cumulative: it sums every run in the session (a fresh session with /new starts it at zero) and appears in the full and nerd presets.
Session tree and agents
| Command | Effect |
|---|---|
/tree | Browse the session entry tree; jump or label entries |
/branch | Branch a new session file from an earlier user message |
/fork | Duplicate the current session into a new file |
/session info | Session metadata and stats |
/agents | Subagent dashboard: the live roster (agent type, status, activity; Enter opens one agent’s session) and the Comms stream of agent-to-agent messages |
/jobs | List background async tool jobs |
/cockpit and /hub are aliases of /agents, as is the app.agents.hub keybinding and a double-tap of the left arrow on an empty composer. They used to open a separate screen with its own roster and its own drill-in, which meant “which agents are running” had two answers that could disagree with each other. They all open the one card now.
One conversation at a time
A process runs more than one conversation at a time. /new stops the previous one
unless session.newKeepsBackground is on, and an ACP client keeps every
open session in the same process. Changing session.newKeepsBackground takes
effect on the next launch; the running session keeps the value it started with
(#928).
The card is scoped to the conversation on screen: the roster, the Comms stream and the transcripts it opens are that conversation’s. A conversation this process is still running off-screen is counted by the status line’s background chip and has no card of its own.
The Live roster
Each row is one agent that exists right now: a status glyph, its call sign, the TYPE of agent it was spawned from (reviewer, scout), its status, how long since it last did anything, and what it is doing. Rows sit in spawn order, oldest first, with your own session at the top. The row you are on is marked two ways, a cursor glyph in the first column and a band across the whole row, so it stays readable on a terminal that renders no colour. Agents from earlier runs of the same session appear too, marked parked, because their transcripts are still on disk even though this process never started them.
Press Enter on a row and the main view becomes that agent’s session: the transcript, the composer, and the status line all point at it, so you read what it is doing and then answer it. Press Esc there to come back to your own session. While you are focused on an agent, the composer footline contains a persistent <agent-id> · esc to go back badge at the left, and the inline Subagents block above the composer lists that session’s own spawns, so inside a leaf agent with no spawns of its own the block disappears. Opening a parked agent revives it on the way in.
To terminate a subagent, select its row and press x, or hover the row and click the [x] at its right edge. Both gestures open the same confirmation card. Choose Dismiss to return without changing the agent, or choose Yes, terminate to abort any turn in flight and release the session. The transcript stays on disk. Your main session and read-only advisor transcripts do not show the termination action.
Clicking elsewhere on a row does the same thing as Enter, and clicking a name in the view strip switches to that view. Opening an agent is reversible with Esc, so a row click opens rather than only moving the cursor. The scroll wheel moves whatever the arrow keys move: the roster cursor on Live, the stream on Comms. Page up and page down move by a screenful, on either view.
The roster is a table, so you can scan a column instead of reading every row: the status, the age, the model and the activity each start at the same place on every line. A name long enough to crowd the row out is truncated rather than paid for by every other row, and the model badge is dropped when the card is too narrow to show enough of it to recognise.
Two agents open a read-only transcript instead of handing the main view over. An advisor is observability-only and is not an addressable peer, so there is nothing on the other end to receive a reply. A collab guest’s agents live on the host, so there is no local session to point at.
The inline task widget also shows the model each subagent runs on, right in its status line, so you can see which model every launched subagent used without opening the Control Center. The Subagents block above the composer in your main session shows the same badge on every running row, so the answer to “what is that one running on” is on screen without opening anything. To show the badge, keep subagent.showResolvedModelBadge on (Subagents settings); turning it off hides it on all three surfaces.
Session files are append-only JSONL under the active profile’s agent sessions/ directory. See Sessions.
Inter-agent messaging
Subagents and the main agent use the irc tool (send, wait, inbox, list) over a process-global mailbox. The Comms view of /agents streams that traffic as it happens, oldest first, including the messages that failed to reach their recipient and the reason they did not land. Long messages are folded to their first few lines with a count of what was hidden; ctrl+o unfolds every message in the view, and pressing it again folds them back. Both ends of a message are labelled with the call sign the Live roster shows for that agent, so you follow a conversation by who is speaking rather than by an id you have to look up on the other view. An agent that has since been released has no call sign left to show, so its messages print its id instead.
The view reads the message bus, not the session files. A subagent’s transcript records what THAT agent received, so a view built from transcripts would show each half of a conversation in a different file and would never show a message that failed to arrive at all. /btw is an ephemeral side question; /tan spawns a background agent for tangential work. (/omfg is unrelated: it forges a TTSR rule from a complaint to stop a recurring behavior.)
Swarm extension
@veyyon/swarm-extension runs multi-agent DAG workflows from YAML (pipeline, parallel, or sequential). Standalone: veyyon-swarm path/to/swarm.yaml. In the TUI, add the package to extensions, then:
/swarm run path/to/swarm.yaml
/swarm status <name>
/swarm help
State and logs: <workspace>/.swarm_<name>/ (state/pipeline.json, logs/*.log).
Keybindings
Remap TUI shortcuts from ~/.veyyon/profiles/default/agent/keybindings.yml (YAML map of action ID → chord or chord
list). Run /hotkeys in a session to see active bindings.
Customize keybindings
app.model.cycleForward: Ctrl+P
app.model.selectTemporary: Alt+P
app.plan.toggle: Alt+Shift+P
app.history.search: [] # disable
Chord names match the UI (Ctrl+P, Alt+Shift+P, Shift+Enter). Older keybindings.json files migrate
to .yml on load.
Action IDs from older releases (interrupt, fork, cursorUp) are renamed to their current IDs
(app.interrupt, app.session.fork, tui.editor.cursorUp) the first time veyyon loads the file, and
the file is written back. The rename happens where the binding already sits, so your comments, blank
lines, and key order come back unchanged:
# hold this one, muscle memory
interrupt: ctrl+x
becomes
# hold this one, muscle memory
app.interrupt: ctrl+x
Common action IDs include app.model.cycleForward, app.model.select, app.plan.toggle,
app.history.search, app.tools.expand, app.thinking.toggle, app.thinking.cycle (Shift+Tab),
app.editor.external (Ctrl+G), app.message.followUp, app.retry, app.display.reset, and
app.clipboard.pasteImage.
Engineering detail: docs/handbook/src/reference/keybindings-config.md.
Slash commands
| Command | Action |
|---|---|
/hotkeys | Show active chords |
/settings | Settings UI (includes keymap-related options) |
Remap keys by editing keybindings.yml; /hotkeys shows the current bindings.
There is no Vim or modal editing mode; the composer uses the bindings above.
Web search
The web_search tool runs a multi-provider search and returns ranked results (and, for some
providers, answer-plus-citations). Use it for current docs, package versions, and online references.
Configuration
Two settings control the tool:
| Setting | Behavior |
|---|---|
web_search.enabled | Boolean, default true. When false, the web_search tool is not offered to the model at all. |
providers.webSearch | Which search backend to use. Default auto walks the configured provider chain; pin a provider id or use providers.webSearchExclude to drop providers. |
Both are in settings → Tools / Providers, or in config.yml:
web_search:
enabled: true
providers:
webSearch: auto
You can also scope this to a profile, so one profile searches the web and
another stays offline. A profile stores its own settings under its agent dir; set the key in
that profile’s config.yml:
# ~/.veyyon/profiles/research/agent/config.yml
web_search:
enabled: false
Provider support
The tool queries a configurable search backend, API-backed providers (using keys you have
configured) or credential-free engines (Startpage, Google, DuckDuckGo, Mojeek, or public, which fans out
to every credential-free engine and consolidates deduplicated results). auto resolves the
first available provider in the built-in priority order; providers.webSearchExclude removes
providers from that chain entirely.
Exa research and webset tools
Beyond ranked search, Exa hosts two MCP servers that Veyyon can turn into agent tools. Both are off by default, because each one adds tools to every session and costs a discovery request at startup.
exa:
enableResearcher: true
enableWebsets: true
exa.enableResearcher adds exa_deep_researcher_start and exa_deep_researcher_check. The
model starts a research run and then polls it, so a single question can take minutes and
returns a written report rather than a result list.
exa.enableWebsets adds one tool per webset operation the server offers, named
exa_<operation>. The list comes from the server, so new operations appear without a Veyyon
release. Websets require EXA_API_KEY in your environment; if it is missing, Veyyon logs the
reason at startup and registers no webset tools rather than failing later inside a tool call.
exa.enabled: false turns off all of it, including search.
Approvals
With web search enabled, the tool runs without a per-call approval prompt, because it reads
public web content rather than touching your machine. If you want the model to never reach
the web, set web_search.enabled: false. See Sandbox and approvals.
Code review
Review surfaces:
/review: bundled interactive review command (branch, commits, or uncommitted work).- Advisor: optional second model that comments on main-agent turns.
- Plan review:
/plan-reviewwhile plan mode is active. - Non-interactive: free-form review prompts under
veyyon -p.
/review
Bundled custom command (packages/coding-agent/src/extensibility/custom-commands/bundled/review).
Launches a review flow over a chosen target and uses the review tool surface (report_finding, …).
See the command help in-session and docs/handbook task guides for example prompts.
Advisor
The advisor is a second model role that reads each main-agent turn and can inject notes
(nit, concern, or blocker). Enable with --advisor or the advisor.enabled setting; assign its
model via the advisor role.
Uses its own context and model assignment when configured.
Plan review
Inside plan mode, /plan-review reopens review of the current plan file. See Plan mode.
Non-interactive
$ veyyon -p "review the uncommitted diff for correctness and missing tests"
$ veyyon -p --yolo "review this branch against main; list P0/P1 findings only"
Typical targets: uncommitted work, a branch delta, or paths named in the prompt. Exit status
follows the print-mode run (veyyon --help).
Approvals
Tool approvals use tools.approvalMode and per-tool policy. See Approvals and
Safety.
Related
Non-interactive mode (veyyon --print)
--print (short -p) runs Veyyon without the interactive TUI: one prompt, tools run under the active approval mode, then exit. Use this from scripts, CI, and other programs.
$ veyyon -p "add a unit test for parse_config and run it"
$ echo "summarize the diff on this branch" | veyyon -p
$ veyyon -p - <<'EOF'
Review src/auth.rs for missing error handling.
EOF
The prompt may be a CLI argument or stdin. If both are present, the piped stdin content is prepended to the argument prompt (stdin first, then the argument, separated by a newline).
Run veyyon --help for the generated flag set. Common options:
Session and config
| Option | Effect |
|---|---|
--no-session | Do not persist the session (an ephemeral run) |
--no-rules | Do not discover or load rules files |
--no-skills | Do not discover or load skills |
--profile <name> | Activate a named profile (-p is --print, not profile) |
--config <file> | Load an extra config overlay for this run, repeatable and never persisted |
--cwd <DIR> | Working directory for the session |
--allow-home | Start in your home directory instead of auto-switching to a temp dir |
Output and models
| Option | Effect |
|---|---|
--mode json | Machine-readable event stream on stdout |
--model / role flags | Model selection for the run |
--approval-mode / --yolo | Approval policy for the run |
Headless runs have no TTY for approval prompts: choose a mode that does not block (--yolo only on disposable runners) or expect the turn to stop when a prompt would be required. See Approvals.
Related
Themes and identity
Veyyon’s interface is built around near-black, near-white, silver structure (#C6CBD4), and a single
ember accent (#F0862E) — the same tokens the website ships.
Bundled themes
| File | Name | Notes |
|---|---|---|
defaults/titanium.json | Titanium | Default dark theme. Pitch black #000000, silver #C6CBD4, ember accent #F0862E; mirrors the website tokens (website/site.css) |
dark.json | Veyyon Dark | Bundled alternative. Pitch black #000000 / #FAFAFA / silver #B8BDC7; predates the ember accent |
light.json | Light | Default light theme. White #FFFFFF ground with dark-silver structure #5C6470, ember accent |
A larger bundled catalog ships under modes/theme/defaults/ and is selectable from the theme picker.
Changing theme
- Settings UI:
/settings→ Appearance → theme (or the theme picker on first run). - Config:
themein~/.veyyon/profiles/default/agent/config.yml(profile-specific when using--profile). - Custom themes: drop JSON under
~/.veyyon/profiles/default/agent/themes/; schema indocs/handbook/src/reference/theme.md.
Terminal capability detection maps the same hierarchy for truecolor, ANSI-256, ANSI-16, unknown background, and no-color modes. Reduced-motion settings remove decorative animation without hiding state changes.
Backgrounds
Veyyon paints no backgrounds by default. The transcript (user messages, tool output, extension messages), the composer, and the status line all inherit your terminal’s own background, so the UI looks native on any terminal color. Two opt-ins bring painted surfaces back:
- Turn off
statusLine.transparent(/settings→ Appearance → Status Line) to paint the theme’sstatusLineBgbar, including powerline end caps. - A custom theme can declare a
composerBgcolor to paint the composer card; when omitted, the composer stays unpainted.
Painted ground
tui.paintGround (/settings → Appearance → Display) controls whether Veyyon sets the
terminal’s own background color (OSC 11) to the theme’s ground while it runs, so the UI
fills the window edge-to-edge instead of floating on the terminal’s configured background.
The original background is restored on exit, including crash exits.
The ground is the theme’s page background, the same export.pageBg color the HTML export
uses (see docs/handbook/src/reference/theme.md). Every built-in theme declares one.
| Value | Behavior |
|---|---|
auto (default) | Paint only when the terminal’s reported background is already close to the theme ground, so no visible seam appears while painting. If the terminal doesn’t report its background, inherit it. |
always | Always paint the theme ground. |
never | Never touch the terminal background. |
On auto, the decision is taken before the launch card paints, from the background this terminal
reported on the previous launch (cache/launch-facts.json). The first launch in a terminal has no
record and inherits; the report that follows paints the ground if it is close enough. A report that
contradicts the record takes effect on the next frame.
A custom theme that declares no page background has no ground to paint, so Veyyon inherits
the terminal’s own background regardless of this setting. With always, it also logs once
that the active theme declares no ground, since that is the one case you asked to paint and
it could not. Add an export.pageBg to the theme to give it a ground.
Terminals that don’t support OSC 11 ignore the sequence; nothing breaks.
What the theme covers
The contract applies to onboarding, composer, menus, dialogs, status line, markdown, tables, diffs, tool output, approvals, progress, and errors, not only the chat pane.
Identity elsewhere
- CLI binary:
veyyon - Config root:
~/.veyyon(VEYYON_CONFIG_DIR; XDG paths afterveyyon config init-xdg) - npm packages:
@veyyon/*
Collab: Live Session Sharing
/collab shares a running session with other Veyyon instances over a relay. Guests open the session in their own TUI (assistant text, tool cards, footer state, /dump); the host process runs the agent and tools. This is not terminal multiplexing.
Quick start
Host:
/collab
prints
Collab session started!
• Join from another terminal: veyyon join "mgAYTZwEnpRQtca0CTgn-Q.gdJUbTovD94ofDaa8YvhY0-ty16w4fn8PgB6PLnoA30"
• or any web browser: share.veyyon.dev/#mgAYTZwEnpRQtca0CTgn-Q.gdJUbTovD94ofDaa8YvhY0-ty16w4fn8PgB6PLnoA30
The browser line is click-to-join (an OSC 8 hyperlink to the full https:// deep link): the relay serves the web guest client at /, and the room id + key ride in the URL fragment. From another veyyon (any directory, any machine), either form works:
Running /collab or /collab view starts or displays the active hosting session, rendering both the terminal/browser join links and their corresponding QR codes.
/join share.veyyon.dev/#mgAYTZwEnpRQtca0CTgn-Q.gdJU…
The guest’s previous session is restored on /leave (or when the host stops).
Commands
| Command | Effect |
|---|---|
/collab | Start sharing full-control (or re-print the link/QR when already hosting) |
/collab <relay> | Start sharing through a specific relay (relay.example.com, ws://localhost:7475) |
/collab view | Start sharing read-only (or re-print the link/QR when already hosting) |
/collab status | Show link + participants |
/collab stop | Stop sharing |
/join <link> | Join a shared session as a guest |
/leave | Leave (guest) or stop sharing (host) |
Link format
Accepted by /join <link> and veyyon join "<link>":
<roomId>.<key> → default relay (wss://share.veyyon.dev)
<roomId>#<key> → legacy bare form
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
host[:port]/r/<roomId>#<key> → legacy direct relay form
https://host[:port]/r/<roomId>.<key> → direct relay URL, normalized to wss://
wss://host[:port]/r/<roomId>.<key> → direct websocket relay URL
ws://localhost:7475/r/<roomId>.<key> → direct plain ws, localhost only
https://host[:port]/#<link> → browser deep link when web UI and relay share a host
https://web-host[:port][/<path>]/#<relay-link> → browser UI wrapper with relay link in the fragment
https://web.example/collab/#relay.example.com/r/<roomId>.<key> → web UI and relay on different hosts
<link> / <relay-link> are parsed recursively as any accepted link above. For http(s) browser wrappers with a parseable fragment, the fragment wins before the HTTP host/path are treated as a relay. This lets https://web.example/collab/#relay.example.com/r/<roomId>.<key> open the web UI at web.example while joining wss://relay.example.com/r/<roomId>. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so https://relay.example.com/r/<roomId>#<key> still means relay relay.example.com.
The trailing .<key> or #<key> part is the room secret, base64url-encoded, in one of two strengths:
- Full link: 48 bytes: the 32-byte AES-256-GCM room key followed by a 16-byte write token. Grants prompting, interrupting, and subagent control.
- View-only link: the bare 32-byte key, no write token. Grants live read access only. Pre-token links parse as view-only.
The room secret is dot-joined in newly generated links because RFC 3986 forbids a raw # inside a URL fragment; parsers still accept legacy # forms and %23-mangled legacy deep links.
End-to-end encryption
Every session payload (entries, events, state, prompts) is sealed with AES-256-GCM before it touches the socket. The relay sees only:
- room ids and connection counts,
- opaque ciphertext frames and their sizes,
- a 4-byte routing prefix (which guest a frame targets).
Possession of the link is the trust boundary: a full link reads and steers the session, a view-only link reads it. Share both like secrets.
Guest permission model
Two trust levels, enforced by the link itself, the host verifies the 16-byte write token at join and rejects writes from peers without it (they appear as read-only in the participants list, and the join notice reports it).
Guests with a full link can:
- read the entire session (including the back-transcript at join time),
- prompt the agent (rendered with their name badge on every participant’s transcript; the LLM sees the prompt text verbatim: names are display-only),
- interrupt the agent (Esc),
- use the subagent dashboard against the host’s subagents: live roster and progress, chat (steers the host’s subagent), kill, and transcript viewing (fetched from the host on demand). A guest reads a transcript rather than taking over a session, because the sessions live on the host.
Guests with a view-only link can read everything live, back-transcript, streaming text, tool cards, subagent transcripts, but the host rejects prompting, interrupting, and agent control from them.
Everything that mutates the host session or machine is host-only: /model, /compact, /resume, /branch, bash (!), python ($), skills, etc. Guests keep a small local allowlist (/dump, /export, /copy, /welcome (aliased /help), /hotkeys, /settings, /leave, /collab, /exit, /quit).
Known v1 limit for guests: a turn already streaming when you join becomes visible from its next message boundary.
Web client
packages/collab-web is a standalone browser client for the same links, no veyyon install needed on the guest side. The relay serves it at /, which is what makes the /collab deep link click-to-join: https://<relay>/#<link> loads the client and auto-connects from the fragment. It renders the live transcript (streaming text, thinking, tool cards), a subagent panel with on-demand transcripts, and a composer with the same guest powers (prompt, interrupt, hub actions). Run bun run dev in the package for a local instance, bun run mock-host for an offline scripted host to develop against, and bun run build to emit a static dist/ deployable anywhere (HTTPS required for WebCrypto). The client never talks to anything but the relay, and the key stays in the URL fragment.
Set collab.webUrl when the browser UI is hosted separately from the websocket relay. When empty, /collab derives http(s)://host[:port] from collab.relayUrl; explicit web UI URLs must use https:// except for http://localhost development origins. The generated browser URL still contains the relay-specific collab link in the fragment.
Settings
| Setting | Default | Meaning |
|---|---|---|
collab.relayUrl | wss://share.veyyon.dev | Relay used by /collab when no relay is passed inline |
collab.webUrl | empty | Browser UI URL for /collab links; empty derives from relay; explicit http:// is allowed only for localhost |
collab.displayName | OS username | Name shown to other participants |
share.serverUrl | https://share.veyyon.dev/s | Share viewer/upload base used by /share (links are <base>/<id>#<key>) |
share.redactSecrets | true | Run the secret obfuscator over /share snapshots before upload |
Self-hosting the relay
The relay is a small content-blind Go service. It keeps no state beyond live connections and exposes:
GET /: the static collab-web guest client (target of the/collabdeep link),GET /r/<roomId>?role=host|guest: WebSocket upgrade,POST /s/GET /s/<id>/GET /s/<id>/raw:/shareblob upload, viewer page, and blob fetch,GET /healthz: liveness.
Close codes
A relay closes a socket with one of four codes when reconnecting would be pointless:
| Code | Reason shown | When |
|---|---|---|
4001 | room closed | The host left, so the room no longer exists. |
4004 | no such room | The room id was never valid, or expired before the join. |
4009 | a host is already connected for this room | A second host tried to take a room that already has one. |
4029 | room is full | The room is at capacity. |
Any other close code is transient, and a client reconnects with exponential backoff up to thirty seconds. That
default is why the table matters: a client that does not recognise a fatal code retries against a condition
that will never clear, quietly, so the codes are declared once in packages/wire/src/relay.ts and both the CLI
and browser clients read them from there. If you write your own relay, close with these codes and these
reasons; a code outside the table tells a client to come back.
Architecture notes
Hub topology, the host is authoritative, guests never peer:
entryframes: durable session entries, broadcast pre-blob-externalization so images stay inline (guests cannot resolve host blob refs). Guests append them verbatim (ids preserved) to a replica session file under~/.veyyon/profiles/<profile>/collab/<roomId>.jsonland into the agent’s message array, which is why/dumpand context estimates work.eventframes: live agent events, fed straight into the guest’s normal event controller; rendering is events-only to prevent double-render.stateframes: debounced footer snapshots: streaming flag, the host’s full model object and thinking level (applied to the guest’s replica agent state, so model display and context-window math are native), host context numbers, and participants.busframes: mirrored task-subagent lifecycle/progress EventBus traffic, republished on the guest’s local bus so the subagent HUD and status-line count work natively.agentsframes: agent-registry snapshots feeding a guest-local registry, so the subagent dashboard roster renders host subagents.
Guest→host: hello, prompt, abort, agent-cmd (hub chat/kill/revive), and fetch-transcript (incremental subagent-transcript reads answered by targeted transcript frames). The replica loads through the regular /resume machinery, so theming, ctrl+o, and transcript behavior are native by construction; the guest process never chdirs to host paths.
Task guides
Short, goal-shaped recipes for common jobs. Each guide points at the deeper feature pages; use those when you need full schemas or edge cases.
Related references: Hooks, Non-interactive mode, MCP, Skills, Memory, Branching, Sandbox.
Automate a check on every edit (hooks)
Goal: every time the agent finishes an edit, run a deterministic check and fail closed when it breaks.
The shipped hook model is a TypeScript module discovered under
~/.veyyon/profiles/<name>/agent/hooks/ (the active profile). Hooks are user-level only: a
.veyyon/hooks/ directory inside a repository is not read. The module exports a factory that registers handlers with pi.on(...).
// ~/.veyyon/profiles/default/agent/hooks/post-edit-check.ts
export default (pi) => {
pi.on("tool_result", async (event) => {
if (!/^(edit|write)$/.test(event.toolName)) return;
// run your check (spawn a test/linter); return { block, reason } from tool_call to deny
});
};
The Bun runtime imports the module at startup; restart (or /reload-plugins) to pick up changes. See
Hooks for the event names and handler contract.
Run a bounded task from a script or CI
Use veyyon --print (-p) when the trigger lives outside the agent (pre-commit, CI, entr, watchexec):
$ veyyon -p \
"Run the focused tests for the files changed in the last commit and fail if any regress"
The prompt can be an argument or piped on stdin. Leave the rung alone unless you have a reason to move it: the default auto auto-approves every tier, while a target outside the working directory, a call that spends a stored credential, a per-tool prompt/deny policy, and a critical command such as a recursive delete of your home directory all still ask, and a headless run has no terminal to ask on, so each of those becomes a failed tool call rather than a silent pass. Do NOT pass --approval-mode ask-command here: it prompts for every exec-tier call, which in -p means every command fails. --yolo auto-approves all tiers and drops the two boundaries (use only on disposable runners). JSON event streams: --mode json. For review, pass a review prompt to -p, or use the passive advisor (--advisor) in the TUI. See Non-interactive mode.
Give the agent a new tool (MCP or skills)
Goal: teach Veyyon a capability you do not want to bake into the binary.
Choose the surface
| Need | Use |
|---|---|
| Talk to an external system (DB, SaaS, browser bridge) over a protocol | MCP server |
| Package reusable instructions, scripts, and examples as data | Skill (SKILL.md) |
Path 1: add an MCP server
Add it from the TUI, which writes mcp.json for you:
/mcp add
Or edit ~/.veyyon/profiles/default/agent/mcp.json directly (the active profile’s file; there is no
project scope):
{
"mcpServers": {
"database": {
"type": "stdio",
"command": "node",
"args": ["/path/to/db-mcp-server/index.js"],
"env": { "DB_PATH": "/var/data/app.db" }
}
}
}
Confirm discovery with /mcp (or /mcp list), then ask the agent to use the new tool by name. If the
server needs OAuth, run /mcp reauth <name>. Details: MCP,
MCP setup.
Path 2: author a skill
Create a skill directory under the active profile, for example
~/.veyyon/profiles/default/agent/skills/audit-config/SKILL.md:
---
name: audit-config
description: Audit Veyyon config.yml for unsafe approval and tool-policy combinations.
metadata:
short-description: Config safety audit
---
# Audit config
When asked to audit configuration:
1. Read the active config.yml.
2. Flag `yolo` approval paired with broad tool allow-lists on untrusted repos.
3. Prefer concrete remediations over generic advice.
Restart or open a new session so skill discovery picks it up. Skills are data, you can version them in
git and share them without shipping a new veyyon build. Prefer a skill when the “tool” is mostly
prompting and local scripts; prefer MCP when the capability is a long-lived external process. See
Skills.
Share context across sessions (memory and branching)
Goal: keep decisions, conventions, and alternate explorations available without pasting transcripts by hand.
Memory: carry guidance into new threads
Cross-session memory is off by default. Turn on a backend with memory.backend in config.yml:
# ~/.veyyon/profiles/default/agent/config.yml
memory:
backend: mnemopi # off (default), local, hindsight, mnemopi
Operate it from the TUI with /memory (/memory stats, /memory diagnose). Keep memory on for repos
where conventions matter; leave it off for throwaway scratch sessions. See Memory.
Branching: explore without losing the main line
Use the session tree when you need parallel context inside one problem.
| Intent | Command |
|---|---|
| Inspect the tree / jump to a prior turn | /tree |
| Copy history into a new session from a user message | /branch |
Typical flow: reach a decision point, /branch to try an alternate approach, continue on the
branch that works. Full behavior: Branching and Sessions.
Memory vs branching
- Memory stores durable facts across sessions when a backend is enabled.
- Branching forks live transcript context for the current problem.
- Branch to explore; use memory for decisions that should outlive one session.
Track long work without repeated reminder walls
Use the todo tool for work that has several independent steps. The session stores every phase,
task, and status. Compaction and handoff keep that complete structured state, including plans with
dozens of items.
When the model tries to finish with open work, Veyyon injects one continuation instruction for that
exact todo state. The instruction states to continue, puts the active task first, shows at most five
open items, and reports how many more remain hidden. It does not replay an unchanged state after a
user says continue or after an unrelated tool call. A real todo change makes the new state eligible
for one reminder, up to the configured limit.
Model-facing todo output and the collapsed TUI use the same sanitized, width-bounded, active-first
projection of at most five items. The complete phases, tasks, and statuses remain in machine state.
To clear that state intentionally, run /todo rm without a task or phase; completing or dropping
items keeps their closed history until it is explicitly removed.
The anchored Todos block above the composer is a railed list. Every phase gets one row with its
tally, so a stage that just closed three tasks does not look like one that has done nothing, and
task rows are drawn for the phase being worked and the few after it. The block is bounded by the
viewport: it never grows past a third of the terminal’s height, it drops finished phases from the
top before it drops work in flight, and it states how many rows it withheld. Expanding it shows every
task of the phases it draws, not an unbounded list — an anchored region that outgrows the screen
cannot be scrolled away from.
Each row’s glyph is its state, before any colour: □ waiting, a breathing cell in flight, ▪ done,
∎ abandoned. A task a detached subagent picked up breathes in that agent’s own accent and names
the agent at the right, which is the same hue its lane carries in the Subagents block. Light
travels down the rail while anything on the board is in flight and the rail is flat while nothing
is, so a board waiting on you is distinguishable from a board being worked. A task closing sweeps a
strike across its text, exhales its glyph and cools from green to grey; when the last task closes,
the whole block makes one pass down its rail and clears. Where display.transitions is off, all of
that is still and the glyphs are static.
Configure the behavior under Settings → Tools → Todos:
- Todo Reminders enables continuation instructions for unfinished plans.
- Todo Reminder Limit caps distinct todo-state reminders before reminders stay silent.
See also
- Configuration for the keys these guides touch
- Examples for prompt-shaped tasks
Examples
Use Veyyon from the repository root for tasks where the harness can inspect files, edit, and verify.
Understand a code path
Explain how model/provider configuration is loaded and where an invalid setting is surfaced to the user.
Veyyon should read the relevant configuration files in your project, name the boundary where state enters, and point at tests or missing tests.
Make a small fix
Fix the config error so it states the invalid file and the setting to change. Add the regression test.
Veyyon should edit through hashline or write, run the focused test (bun test in the relevant
package), and stop when the test proves the behavior.
Improve docs with code truth
Make the MCP setup page match the MCP config loader in this project. Verify against the schema.
Inspect the live schema source, update the handbook, and avoid claims not backed by code. Engineering
notes live under docs/.
Review a change
Review the staged diff for correctness, security, missing tests, and public/private boundary leaks.
A useful review names concrete files and lines, separates correctness from style, and recommends the
smallest fix that makes the behavior true. Enable the advisor watchdog (advisor.enabled) when a
second model should comment on each turn.
Recover a malformed tool call
Use the edit tool with a stale hashline tag and observe the mismatch error.
Malformed tool JSON is repaired when the fix is unambiguous; otherwise the call returns an error tool result with hints rather than dispatching garbage. Hashline returns actionable stale-tag errors. See Repair overview.
Continue through long context
Keep the security requirement, touched files, and next action intact after compaction.
Use /compact with a focus string; goal mode (/goal) preserves objectives across compaction when
enabled. See Compaction and memory.
Verify before claiming done
Run the package test gate for the area you changed.
Example: bun run test in packages/coding-agent, or the Rust + TypeScript CI matrix documented in CONTRIBUTING.md when touching Rust natives.
Use the model/provider contract
Point Veyyon at a provider model and rely on the same harness contract every provider path must satisfy.
See Model contract and Providers.
Recorded end-to-end workflow
The landing-page recording is one operator task carried to a signed artifact, in a single unbroken
session. The task audits the numeric environment defaults of a small service. Before submission,
/secret from-env stores a synthetic release key as the placeholder #RELEASE_SIGNATURE#.
The model writes a three-phase, six-task plan and holds it until told to start. It fans three
directory-scoped refactors out to parallel workers, one per directory, applies the edits itself where
the change is one guard, and verifies that all nine documented defaults resolve in an environment
stripped of every one of those variables. It then signs its work in one bash call: the sha256 of
#RELEASE_SIGNATURE# appended to SIGNED.md as a single line. Veyyon resolves the placeholder only
at the outbound tool boundary and requires approval before the call runs, so the credential itself is
never printed and never reaches the transcript. The board closes 6/6.
The recorded clip runs the whole task. Untouched screens are shortened rather than accelerated.
See Testing and verification for the recording environment and the regeneration command.
Plan mode and goals
Plan mode and goal mode are separate engine modes with different tools and continuation behavior. They cannot be active together (and each conflicts with vibe mode).
Plan mode
Read-focused exploration that drafts a plan file before implementation.
Enabling
- Setting:
plan.enabled(defaulttrue;/settings→ Tasks → Modes → Plan Mode) - Slash:
/plantoggles plan mode;/plan <prompt>enters plan mode and submits the prompt /plan-reviewreopens plan review while plan mode is active
Behavior
- Session selects a plan file path and records plan-mode state.
- Tool surface adjusts:
resolveis available for plan approval; plan-filewrite/editmay be enabled for drafting. - The agent explores and writes the plan (read-oriented work plus plan-file edits).
- Finalization uses the
resolvetool with plan-approval semantics (plan_approval). - Exit:
/planagain (confirmation if a draft exists). Session recordsmodeentries in the session file.
When configured, plan mode uses the plan model role.
Goal mode
A persistent objective on a saved session, with optional auto-continuation when idle.
Enabling
- Setting:
goal.enabled(defaulttrue) /goal set <objective>: create or replace/goal show: status and token usage/goal pause//goal resume/goal drop: remove- Setting:
goal.modelBudgetsEnabled(defaultfalse), controlled only from the interactive Settings UI /guided-goal: interview flow before enabling
Goal state
Stored on the session. Fields include:
id, objective, status, tokenBudget?, tokensUsed, timeUsedSeconds, createdAt, updatedAt
Statuses: active, paused, budget-limited, complete, dropped.
Goal tool
When goal mode is active, the agent can call the goal tool with ops: create, get, complete, resume, drop. The tool never accepts a budget argument. The interactive Settings UI owns goal.modelBudgetsEnabled, which controls whether persisted budgets are exposed and enforced. Continuation prompts inject on idle turns per goal.continuationModes.
Example
$ veyyon
/goal set Add a --max-time flag to the print-mode runner and document it
Use /goal show for progress. Pause with /goal pause. Complete via the goal tool or /goal drop.
Architecture notes: Goal state. Sessions: Sessions.
Vibe mode
/vibe toggles vibe mode. The main agent becomes a director with a reduced tool set
(read, vibe_spawn, vibe_send, vibe_wait, vibe_kill, vibe_list) and drives worker CLIs
(fast / good model lanes) instead of editing files itself.
Mutual exclusion: plan mode, goal mode, and vibe mode cannot run together; the TUI warns if you try to enter one while another is active.
Permissions: vibe_spawn and vibe_send are exec-level tool calls, so starting a worker or handing
it a new instruction is gated by the session approval mode exactly like running a command (vibe_wait,
vibe_kill, and vibe_list are read-level). Each worker then runs headless with the full tool set
(edit, write, bash, …) and executes autonomously, a detached subagent has no UI to confirm prompts
against, so approving the spawn is the authorization boundary. Your tools.approval allow/deny policies
still apply inside every worker, so path and command denials you have configured are enforced there too.
Workers are killed when you leave vibe mode, so none outlive the director that drives them.
Skills
A skill is a folder of instructions you drop into your profile, and the agent picks it up on its own. Use one to teach Veyyon a repeated task: how your project runs its tests, the steps of a release, the shape of a code review. Skills live on disk, not in the binary, so you add or change one by editing a file, with no rebuild.
For general information on Veyyon extension capabilities, see Tools, skills, and extension data.
Skill locations
Skills load only from the active profile. Veyyon reads these three locations,
all under $HOME/.veyyon/profiles/<profile>/agent (profiles/default/ when you
have not selected a profile):
| Scope | Location | Description |
|---|---|---|
| User | .../agent/skills | Skills you author or install for the active profile. |
| Managed | .../agent/managed-skills | Auto-learn skills Veyyon writes itself. A same-named user skill always wins. |
| Plugins | plugins installed into the active profile | Skills bundled with a plugin you added to this profile. |
Nothing else contributes skills. There is no autodiscovery from across your
computer: another tool’s skill directory ($HOME/.claude/skills,
$HOME/.codex/skills, $HOME/.agents/skills, .github/skills, and the rest) is
never scanned, and a project-local .veyyon/skills directory next to your code is
not read either. Skills belong to your profile, so switching profiles switches
the whole skill set, and no repository you open can inject a skill into a session.
Full provider list and dedup rules: docs/handbook/src/reference/skills.md.
Importing another tool’s skills
Because foreign skills never load on their own, you bring one into Veyyon by
importing it. The onboarding import scan finds user-level skills and instruction
files that other AI tools (Claude, Codex, Gemini, Cursor, and similar) left on
disk, and copies the ones you pick into the active profile’s skills directory.
The copy is profile-owned from then on, so it loads like any other profile skill
and is not affected by the original tool.
A separate setting, discovery.importForeignConfig, governs whether Veyyon
ambiently reads other tools’ context files (CLAUDE.md, standalone AGENTS.md),
rules, and MCP servers. It ships off, so by default Veyyon reads no foreign
tool’s config directory and no GEMINI.md. It does still read a project’s own
AGENTS.md or CLAUDE.md on the walk from the repository root down to your
working directory: those are the project’s instructions to any agent, not another
tool’s private config. Turn the setting on to load the rest as a machine-wide
base layer:
discovery:
importForeignConfig: true
The setting does not change skill loading: foreign skills are never loaded ambiently whether it is on or off. It also does not gate the import scan. The onboarding scan always finds and offers foreign files for import, because importing copies a file into your profile, which is how foreign config comes in by default now that ambient loading is off.
Veyyon’s own instructions load in four layers, and only these four:
- The compiled system prompt.
- The global
~/.veyyon/AGENTS.md, which applies to every profile. - The project’s own context files: one file per directory on the walk from
the repository root down to your working directory. Each directory offers
.veyyon/AGENTS.md(only from the nearest non-empty.veyyon/), thenAGENTS.md, thenCLAUDE.md, and the first one with content wins. The rest of that directory’s candidates are not read, so aCLAUDE.mdsitting beside anAGENTS.mdis deliberately not loaded and the same rules are never inlined twice. The choice is made per directory, so a repository root usingAGENTS.mdand a package usingCLAUDE.mdboth load. - The active profile’s
AGENTS.md(~/.veyyon/profiles/<name>/agent/AGENTS.md).
That list is the order they are RESOLVED in, not the order of authority. They are
rendered least authoritative first, so the strongest file has the last word: the
project files come first, then the profile file, then the global
~/.veyyon/AGENTS.md last of all. Your live instruction in the conversation beats
every one of them. A narrower file may add detail a broader one does not cover,
but it never contradicts, loosens, or forbids what a broader one allows, because a
project file is content checked into a repository you may not have written. Within
the project layer the file closest to your working directory is the most specific
one. See
instruction layers below for how to split rules between
the global and per-profile files.
Instruction layers
Veyyon reads two AGENTS.md files that you own, plus the project file:
~/.veyyon/AGENTS.mdis the global file. Put rules here that should hold in every profile.~/.veyyon/profiles/<name>/agent/AGENTS.mdis the profile file. Put rules here that apply only to that profile.
Keep each rule in one place. A rule that belongs to every profile goes in the global file; a rule that is specific to one profile goes in that profile’s file. Splitting them this way avoids duplicating the same guidance across profiles.
Veyyon creates the global file for you on first run with a short note at the top
explaining this split. The note is an HTML comment wrapped in Veyyon markers,
and Veyyon strips it before sending the file to the model, so it never spends any
of your instruction budget. It is there for you when you open the file to edit
it, not for the agent. A new profile’s AGENTS.md gets the same kind of note.
Delete the note if you like; Veyyon does not add it back.
Profiles isolate skills
Each profile is a separate config root
($HOME/.veyyon/profiles/<name>/agent), and every skill source resolves under
that root, so profiles never share a skill directory. Switching profiles re-homes
user skills, managed (auto-learn) skills, and plugin skills to the active
profile, and all skills.* settings are stored per profile. One profile can hold
a large skill set while another stays empty.
Skill structure
Each skill is defined in its own subdirectory containing a SKILL.md file.
The skill file (SKILL.md)
The SKILL.md file defines the skill’s system prompt instructions and must start with a YAML frontmatter block delimited by ---.
Here is an example SKILL.md file.
---
name: my-custom-skill
description: Performs a custom code audit or analysis.
---
# My Custom Skill
Use this skill when analyzing source files. Ensure you focus on:
1. Logic errors.
2. Unhandled edge cases.
The frontmatter contains these fields.
name: The name of the skill (optional). Defaults to the name of the parent folder.description: A description of what the skill does (required). A skill without one is skipped at load time.enabled: Set tofalseto skip the skill at load time (optional).hide/disableModelInvocation: Either one hides the skill from the model-facing list (optional).
Configuration
Skills are configured in the skills block of Veyyon’s config.yml file.
Master switch
skills.enabled (default true) turns skill discovery off entirely:
skills:
enabled: false
Skill commands
enableSkillCommands (default true) controls whether skills also register as
/skill:name commands.
skills:
enableSkillCommands: false
There are no per-source toggles and no customDirectories setting. Skills load
only from the active profile (see Skill locations), so there
is nothing to enable or disable per source. To use a skill from another tool,
import it into your profile.
Manage individual skills
includeSkills and ignoredSkills are glob lists matched against skill names. An empty
includeSkills means every discovered skill is active; ignoredSkills then subtracts.
skills:
ignoredSkills:
- my-custom-skill
- internal-*
Interactive TUI controls
In the terminal user interface, you can manage and list skills interactively.
Slash commands
/extensionsopens the Extension Control Center, which lists every discovered skill alongside tools and hooks, and lets you enable or disable individual skills.
Toggles persist immediately to disabledExtensions; there is no close-time summary message.
Related recipes
For goal-shaped “give the agent a new capability” flows that stitch skills with MCP and plugins, see Task guides.
Engineering detail: docs/handbook/src/reference/skills.md.
Skills authoring
A skill is a folder that adds a reusable capability to Veyyon. For how skills are discovered and loaded, see Skills.
Directory structure
Skills load only from the active profile. Veyyon reads these locations, all under the profile’s agent dir (profiles/default/ when you have not selected a profile):
| Scope | Directory | Purpose |
|---|---|---|
| User | $HOME/.veyyon/profiles/<profile>/agent/skills | Skills you author or install for the active profile. |
| Managed | $HOME/.veyyon/profiles/<profile>/agent/managed-skills | Auto-learn skills Veyyon writes itself. A same-named user skill always wins. |
| Plugins | plugins installed into the active profile | Skills bundled with a plugin you added to this profile. |
Nothing else contributes skills. A project-local .veyyon/skills directory and another tool’s skill directory ($HOME/.claude/skills, $HOME/.codex/skills, $HOME/.agents/skills, and the rest) are never scanned. To use a skill from another tool, import it into your profile, see Skills. For the full provider list and dedup rules, see Skills.
Create a new skill by making a directory inside the profile’s skills dir and adding a SKILL.md file. The name of the directory is the default name of the skill.
A skill directory may contain additional files:
my-skill/
├── SKILL.md
└── scripts/, references/, assets/ ...optional
Only SKILL.md is required. The other files are loaded when the skill is active or when the model requests them.
SKILL.md frontmatter
Every SKILL.md must begin with a YAML frontmatter block between --- lines.
---
name: my-skill
description: Describe what this skill does and when to use it.
---
The frontmatter fields are:
name: The skill identifier. Optional; defaults to the parent directory name. Use lowercase letters, digits, and hyphens. Keep it under 64 characters.description: A clear explanation of what the skill does and when it should be triggered. This is the main signal the model uses to decide whether to invoke the skill.enabled: Set tofalseto skip the skill at load time.hide/disableModelInvocation: Either one hides the skill from the model-facing list.
Be specific in the description. A vague description makes the skill less likely to be selected at the right moment.
Writing the body
The body of SKILL.md is a Markdown document that contains the instructions, context, and workflow for the skill. The body is loaded only after the skill has been selected, so the frontmatter acts as the gate and the body acts as the guide.
Guidelines for the body:
- State the purpose at the top.
- List the conditions that trigger this skill.
- Provide a step-by-step workflow or a set of rules the model should follow.
- Include examples of inputs and expected outputs.
- Mention any bundled scripts, references, or assets and when to use them.
- Keep it concise. Long skills consume context and may be ignored. Split detailed reference material into files under
references/and link to them fromSKILL.md.
Example body:
# Code review
Use this skill when the user asks for a review of a code change or pull request.
1. Check for logic errors, unhandled edge cases, and test coverage.
2. Verify that the change matches the project style and conventions.
3. Flag any breaking changes or missing documentation.
4. Report findings as a numbered list with file paths and line numbers.
Do not leave comments on external platforms unless the user explicitly asks for it.
Configuring in config.yml
There is no registration step: a skill placed in any discovered directory (see
Skills) loads automatically. The skills section of config.yml controls
which discovered skills are active.
Turn skill discovery off entirely:
skills:
enabled: false
Filter individual skills by name glob (includeSkills allowlist, ignoredSkills
denylist):
skills:
ignoredSkills:
- internal-*
includeSkills is the allowlist twin: when it is non-empty, only matching skills load.
Worked example: a profile skill
This example creates a skill in your active profile that adds a custom onboarding check.
Create the skill directory under the active profile (profiles/default when you have not selected one):
mkdir -p ~/.veyyon/profiles/default/agent/skills/onboarding-check
Create onboarding-check/SKILL.md in that directory:
---
name: onboarding-check
description: Review the project for missing onboarding files and recommend improvements.
---
# Onboarding check
Use this skill when the user asks whether the project is ready for a new contributor.
1. Check that the project has a README, CONTRIBUTING guide, and LICENSE file.
2. Verify that the build command is documented and can be run from the README.
3. List any missing or incomplete files.
4. Suggest concrete additions that would help a new contributor start quickly.
Report the result as a short checklist with `done` or `missing` for each item.
No registration is needed, a skill under the active profile’s skills directory is
picked up automatically. If your config.yml uses an includeSkills
allowlist, add the skill’s name to it:
skills:
includeSkills:
- onboarding-check
Invoking the skill
In the TUI, you can invoke the skill in two ways:
- Open
/extensionsto confirm the skill is enabled, then invoke it with/skill:onboarding-check(available whenskills.enableSkillCommandsis on). - Type a natural request such as “Is this project ready for a new contributor?” in the composer. The model reads the skill description and selects the skill automatically when the request matches.
From a command-line invocation, refer to the skill by its name. The exact command depends on the Veyyon CLI version; run veyyon --help or see the CLI reference for the current syntax.
Plugins
A plugin bundles several extensions into one installable package: skills, MCP servers, hooks, and related assets that ship and update together. Reach for a plugin when you want to share a whole capability at once instead of wiring each piece by hand. See Connectors for the current integration surface (MCP, plugins, hooks, and skills).
Plugin Structure
Every plugin is a directory with a .claude-plugin/plugin.json manifest file (the Claude
Code-compatible path). The manifest describes the plugin’s metadata and lists its integration points.
Plugin Manifest (plugin.json)
Veyyon reads these fields from plugin.json:
| Field | Type | Description |
|---|---|---|
name | String | The unique name of the plugin. Lowercase ASCII alphanumerics with interior dots and hyphens (no leading/trailing separator, no underscores), at most 64 characters. |
version | String | The version of the plugin (optional). When omitted, the installed version resolves in this order: marketplace catalog entry version, then this manifest or package.json, then the source git SHA truncated to 7 characters, then "0.0.0". |
description | String | A description of the plugin (optional). |
skills | String or Array of Strings | Path or paths to directories containing skill definitions (optional). |
commands / slash-commands | String or Array of Strings | Path or paths to command definitions (optional). |
Other plugin content loads from conventional locations rather than manifest fields: MCP servers from
a .mcp.json file at the plugin root, and hooks from .ts or .js modules under the plugin’s
hooks/pre/ and hooks/post/ directories. A hook is loaded as an extension module and must default-export
a factory, so a shell script placed in those directories is discovered but not run; veyyon reports each
one it had to skip.
Marketplaces
Marketplaces are collections of plugins. A marketplace is a directory or Git repository containing a marketplace.json catalog manifest.
Veyyon checks the following relative paths under a marketplace root to locate its catalog manifest:
.veyyon-plugin/marketplace.json(preferred).claude-plugin/marketplace.json(Claude Code-compatible fallback)
The marketplace catalog requires a name, an owner.name, and a plugins list. Each plugin entry
requires only a name and a source; optional entry metadata includes description, version,
author, homepage, repository, license, keywords, category, tags, strict, and embedded
capability fields (commands, agents, hooks, mcpServers, lspServers, dapAdapters). A source
can point to a local directory, a Git repository (with optional branch, tag, commit ref, or
subdirectory path), a URL, or an npm package.
File Locations
Plugin install state is profile-scoped under ~/.veyyon/profiles/<profile>/plugins/ (default profile: profiles/default/plugins/). Config root is relocatable with VEYYON_CONFIG_DIR.
| Path | Description |
|---|---|
~/.veyyon/profiles/<profile>/plugins/installed_plugins.json | User-scope marketplace install registry |
~/.veyyon/profiles/<profile>/plugins/node_modules/ | npm/git/link plugin packages |
~/.veyyon/profiles/<profile>/plugins/cache/ | Cached marketplace catalogs and plugin trees |
~/.veyyon/profiles/<profile>/plugins/veyyon-plugins.lock.json | Enabled state, selected features and stored settings for every npm, git and linked plugin |
Project .veyyon/plugins/installed_plugins.json | Project-scope marketplace installs |
Command Line Interface
You can manage plugins and marketplaces using the veyyon plugin and veyyon plugin marketplace command groups.
Managing Plugins
Install a Plugin
veyyon plugin install accepts four kinds of target:
| Target | Form | Example |
|---|---|---|
| Marketplace | name@marketplace | sample@debug |
| npm | name, @scope/name, either with @version | @veyyon/[email protected] |
| Git | github:user/repo, gitlab:, bitbucket:, codeberg:, sourcehut:/srht:, or a full git URL, each with an optional #ref | github:user/repo#v1.0 |
| Local path | a path to the plugin’s own directory | ./path/to/plugin |
$ veyyon plugin install sample@debug
$ veyyon plugin install @veyyon/exa
$ veyyon plugin install github:user/repo#v1.0
$ veyyon plugin install ./path/to/plugin
A local path is linked rather than copied: veyyon symlinks the directory into the profile’s
node_modules, so edits to the source appear without a reinstall. veyyon plugin link <path> does
the same thing, and either verb works.
Use --force to reinstall over an existing install and --scope user|project to choose the install
scope. --scope applies to marketplace installs only; npm, git and local installs warn and ignore it.
--json prints the installation result as JSON for npm, git and link installs; marketplace installs
ignore it.
--dry-run resolves the target and reports the name and version it resolves to, without writing a
dependency, a lockfile entry or a node_modules entry:
$ veyyon plugin install github:sindresorhus/slugify --dry-run
[dry-run] Would install @sindresorhus/slugify@github:sindresorhus/slugify#7c318bd
A git target’s package name comes from the repository, not the spec, so a dry run is how you learn the name a git plugin will install under. A target that cannot be resolved — an unpublished npm name, a version that does not exist, a private or missing repository — fails with the resolver’s output and exits 1. A dry run proves the target resolves; it does not prove the package is a veyyon plugin, because nothing is unpacked and no manifest is read.
List Plugins
List installed plugins and their statuses.
$ veyyon plugin list
Options:
--json: Print the output as JSON.
Uninstall a Plugin
Uninstall a plugin from local cache and config. Pass the name veyyon plugin list shows, which for a
git or local plugin is the package’s own name rather than the spec you installed it with.
$ veyyon plugin uninstall sample@debug
$ veyyon plugin uninstall @veyyon/exa
$ veyyon plugin uninstall linked-plugin
Every install route is removable by the same command. Uninstalling a linked plugin removes the symlink and the plugin’s stored settings, and leaves the directory it pointed at untouched: that is your working copy, not veyyon’s.
Use the --json flag to return the removal result as JSON for npm plugins; marketplace uninstalls
ignore it.
To keep a plugin installed but inert, use veyyon plugin disable <name>, which leaves it listed and
reversible with enable.
Check Plugin Health
Report what is wrong with your plugin installation, and optionally repair it.
$ veyyon plugin doctor
$ veyyon plugin doctor --fix
$ veyyon plugin doctor --json
Each check reports ok, a warning, or an error. doctor exits 1 when any error is left unrepaired
and 0 otherwise, so you can gate a script on it. Warnings never affect the exit code, and an error
that --fix repaired does not either. --json prints the checks as an array instead of the
human-readable report.
On a machine with no plugins installed every check is ok: nothing is missing, because nothing was
ever installed. That is the state a fresh install is in, and doctor is quiet about it on purpose.
The checks are:
plugins_directory,package_manifest,node_modules: the three things a plugin install needs. Each one distinguishes “not created yet” from “there but unreadable”. The first is normal and reportsok; the second is an error stating the path, because a plugins directory whose permissions have been mangled looks identical to an empty one from the outside and the fix ischmod, not a reinstall. A profile holding only linked plugins has nopackage.json, because linking writes no dependency;package_manifestreportsokand states how many linked plugins are present.plugin:<name>: one per installed plugin. An error means the package is missing fromnode_modulesor has nopackage.json. A warning means it loaded but contains no plugin manifest, so veyyon can see the package and cannot use it.plugin:<name>:tools,:hooks,:extension:<path>: an entry point the manifest names and the package does not contain.plugin:<name>:feature:<feature>: a feature you enabled that the plugin’s manifest does not define, usually because the plugin dropped it in an update. A warning, since the plugin still works.orphan:<name>: a plugin your config enables that is not installed. A warning, since your config is intact and only the package is gone.plugin_config,installed_registry: reported only when one of those files cannot be read.doctorcontinues and reports the rest, so one unreadable file does not cost you the whole report.
--fix repairs what can be repaired without a decision: it runs an install for a missing package,
drops an orphaned config entry, and removes an enabled feature the manifest does not define. A check
that was repaired is reported. Everything else is left for you, because the remedy depends on what you
meant.
Managing Marketplaces
Add a Marketplace
Add a local path or Git repository to your configured marketplace sources.
$ veyyon plugin marketplace add ./path/to/marketplace
$ veyyon plugin marketplace add owner/repo
$ veyyon plugin marketplace add https://github.com/owner/repo
List Marketplaces
List all configured marketplaces and their sources.
$ veyyon plugin marketplace list
Update Marketplaces
Fetch the latest revisions for configured Git marketplaces. Omit the marketplace name to update all configured Git marketplaces.
$ veyyon plugin marketplace update
$ veyyon plugin marketplace update debug
Remove a Marketplace
Remove a configured marketplace by name.
$ veyyon plugin marketplace remove debug
The plugin marketplace subcommands print human-readable output only; --json has no effect on them.
TUI Integration
Slash Commands
/plugins: Lists installed npm and link plugins./extensions: Opens the Extension Control Center dashboard, which shows plugin-provided skills, tools, and hooks alongside everything else that is loaded.
The Plugins tab of /settings lists installed npm and marketplace plugins and toggles each one on or
off. Browsing and installing happen through the veyyon plugin CLI.
Registry Files
Marketplace and plugin state is not kept in config.yml. Two JSON registries, managed
by the veyyon plugin CLI (and the /settings Plugins tab for the enabled toggle; edit through
those, not by hand):
marketplaces.json(~/.veyyon/profiles/<profile>/marketplaces.json, the profile root besideagent/andplugins/): which catalogs you have added. Each entry records the marketplacename,sourceType,sourceUri,catalogPath, and added/updated timestamps.installed_plugins.json(under the plugins dir): which plugins are installed. Each entry is keyed<plugin_name>@<marketplace_name>and records the installscope(user or project),installPath,version, install/update timestamps, the source git commit, and anenabledtoggle.
The one plugin-related key that does live in config.yml is marketplace.autoUpdate, which
controls the startup update check. It runs in the background, so it never delays the first
paint, and it takes one of three values:
notify(the default) refreshes any marketplace catalog older than a day, compares your installed versions against it, and prints one line with how many updates are available. Install them withveyyon plugin upgrade(all) orveyyon plugin upgrade <name>@<marketplace>.autodoes the same check and installs the updates itself, then prints one line with how many landed. The running session keeps the versions it loaded at startup, so restart to use the new ones.offskips the check entirely and contacts no marketplace.
A check that fails, usually because you are offline, is written to the log and does not interrupt the session.
Related recipes
Plugins are installed through the veyyon plugin CLI above, there is
no model-facing plugin-install tool. For task-shaped recipes that combine plugins with MCP and
skills, see Task guides.
Extensions
Primary guide for authoring runtime extensions in packages/coding-agent.
Extension runtime modules:
src/extensibility/extensions/types.tssrc/extensibility/extensions/runner.tssrc/extensibility/extensions/wrapper.tssrc/extensibility/extensions/index.tssrc/modes/controllers/extension-ui-controller.ts
For discovery paths and filesystem loading rules, see extension-loading.md.
For packaged user-facing extension CLIs/features such as packages/swarm-extension, see user-facing-packages.md.
What an extension is
An extension is a TS/JS module exporting a default factory:
import type { ExtensionAPI } from "@veyyon/coding-agent";
export default function myExtension(pi: ExtensionAPI) {
// register handlers/tools/commands/renderers
}
Extensions can combine all of the following in one module:
- event handlers (
pi.on(...)) - LLM-callable tools (
pi.registerTool(...)) - slash commands (
pi.registerCommand(...)) - keyboard shortcuts and flags
- custom message rendering
- session/message injection APIs (
sendMessage,sendUserMessage,appendEntry)
Runtime model
- Extensions are imported and their factory functions run.
- During that load phase, registration methods are valid; runtime action methods are not yet initialized.
ExtensionRunner.initialize(...)wires live actions/contexts for the active mode.- Session/agent/tool lifecycle events are emitted to handlers.
- Every tool execution is wrapped with extension interception (
tool_call/tool_result).
Extension lifecycle (simplified)
load paths
│
▼
import module + run factory (registration only)
│
▼
ExtensionRunner.initialize(mode/session/tool registry)
│
├─ emit session/agent events to handlers
├─ wrap tool execution (tool_call/tool_result)
└─ expose runtime actions (sendMessage, setActiveTools, ...)
Important constraint from loader.ts:
- calling action methods like
pi.sendMessage()during extension load throwsExtensionRuntimeNotInitializedError - register first; perform runtime behavior from events/commands/tools
Quick start
import type { ExtensionAPI } from "@veyyon/coding-agent";
export default function (pi: ExtensionAPI) {
const { z } = pi.zod;
pi.setLabel("Safety + Utilities");
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Extension loaded in ${ctx.cwd}`, "info");
});
pi.on("tool_call", async (event) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
return { block: true, reason: "Blocked by extension policy" };
}
});
pi.registerTool({
name: "hello_extension",
label: "Hello Extension",
description: "Return a greeting",
parameters: z.object({ name: z.string() }),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}` }],
details: { greeted: params.name },
};
},
});
pi.registerCommand("hello-ext", {
description: "Show queue state",
handler: async (_args, ctx) => {
ctx.ui.notify(`pending=${ctx.hasPendingMessages()}`, "info");
},
});
}
Extension API surfaces
1) Registration and actions (ExtensionAPI)
Core methods:
on(event, handler)registerTool,registerCommand,registerShortcut,registerFlagregisterMessageRenderer,registerAssistantThinkingRenderersetLabel,getFlagsendMessage,sendUserMessage,appendEntry,execgetActiveTools,getAllTools,setActiveToolsgetCommandsgetSessionName,setSessionNamesetModel,getThinkingLevel,setThinkingLevelregisterProviderevents(shared event bus)
In interactive mode, input handlers run before the built-in first-message auto-title check. Extensions that call await pi.setSessionName(...) from input can set the persisted session name and prevent the default auto-generated title from running for that session.
Also exposed:
pi.loggerpi.typebox(zod-backed compatibility shim for legacy TypeBox-style schemas)pi.zod(injectedzod/v4module: canonical for tool parameter schemas)pi.pi(package exports)
Message delivery semantics
pi.sendMessage(message, options) supports:
deliverAs: "steer"(default): interrupts current rundeliverAs: "followUp": queued to run after current rundeliverAs: "nextTurn": stored and injected on the next user prompttriggerTurn: true: starts a turn when idle (also honored withdeliverAs: "nextTurn": idle prompts immediately; while streaming the queued message schedules an internal continuation)
pi.sendUserMessage(content, { deliverAs }) always goes through prompt flow. Omit deliverAs to start a normal prompt when idle; while streaming, omitted deliverAs queues the message as a steer. Set deliverAs: "followUp" to wait until the current run finishes.
2) Handler context (ExtensionContext)
Handlers and tool execute receive ctx with:
uihasUIcwdsessionManager(read-only)modelRegistry,modelmodels(read-only model query: see below)getContextUsage()compact(...)isIdle(),hasPendingMessages(),abort()shutdown()getSystemPrompt()memory(optional structured memory runtime: status/search/save across the configured backend)
Model selection (ctx.models)
ctx.models is a read-only facade for picking and comparing models the same way core does:
list(): authenticated models available this session.current(): the live session model (read lazily, so it reflects/modelswitches).resolve(spec): a model string (provider/id, bare id) or role alias (@slow, a configured role) →Model, honoring the same settings-backed aliases and match preferences as--model. Returnsundefinedwhen nothing matches.family(model): an opaque lineage token for “same family?” checks (Claude point releases share a token; Claude and GPT differ). Compare it; don’t persist it (the vocabulary tracks new releases).
// Pick a model from a different family than the current one (e.g. a cross-family reviewer).
const current = ctx.models.current();
const contrasting = ctx.models
.list()
.find(m => current && ctx.models.family(m) !== ctx.models.family(current));
3) Command context (ExtensionCommandContext)
Command handlers additionally get:
waitForIdle()newSession(...)switchSession(...)branch(entryId)navigateTree(targetId, { summarize })reload()
Use command context for session-control flows; these methods are intentionally separated from general event handlers.
Event surface (current names and behavior)
Canonical event unions and payload types are in types.ts.
Session lifecycle
session_startsession_before_switch/session_switchsession_before_branch/session_branchsession_before_compact/session_compacting/session_compactsession_before_tree/session_treesession_shutdown
Cancelable pre-events:
session_before_switch→{ cancel?: boolean }session_before_branch→{ cancel?: boolean; skipConversationRestore?: boolean }session_before_compact→{ cancel?: boolean; compaction?: CompactionResult }session_before_tree→{ cancel?: boolean; summary?: { summary: string; details?: unknown } }
Prompt and turn lifecycle
inputbefore_agent_start: receives the base system prompt. Memory context (recalled memories, mental models) is not part of it: that content is delivered as a message on the turn instead, because changing the system prompt mid-session throws away the provider’s cache prefix.before_provider_request(may replace provider request payload)after_provider_responsecontextagent_start/agent_end: agent loop lifecycle notification;agent_endremains notification-onlysession_stop: main-session stop hook, awaited before settle; may continue with{ continue: true, additionalContext }or{ decision: "block", reason }; capped at 8 consecutive continuations and never fires for task/subagent sessionsturn_start/turn_endmessage_start/message_update/message_end
Tool lifecycle
tool_call(pre-exec, may block)tool_result(post-exec, may patch content/details/isError)tool_execution_start/tool_execution_update/tool_execution_end(observability)tool_approval_requested/tool_approval_resolved(observability; emitted bywrapper.tsonly when a tool requires approval and an approval handler is registered)
tool_result is middleware-style: handlers run in extension order and each sees prior modifications.
Reliability/runtime signals
auto_compaction_start/auto_compaction_endauto_retry_start/auto_retry_endttsr_triggeredtodo_remindergoal_updatedcredential_disabled
User command interception
user_bash(override with{ result })user_python(override with{ result })
resources_discover
resources_discover exists in extension types and ExtensionRunner.
Current runtime note: ExtensionRunner.emitResourcesDiscover(...) is implemented, but there are no AgentSession callsites invoking it in the current codebase.
Tool authoring details
registerTool uses ToolDefinition from types.ts.
Current execute signature:
execute(
toolCallId,
params,
signal,
onUpdate,
ctx,
): Promise<AgentToolResult>
A custom tool (a file under tools/, documented in ../using/custom-tools.md) takes the
same five arguments in a different order, with the signal last:
execute(toolCallId, params, onUpdate, ctx, signal). Copying one into the other
place raises no error at the call site and none at runtime either: the arguments
still arrive, so ctx is the update callback and the first ctx.sessionManager
you touch is undefined.
Template:
const { z } = pi.zod;
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "...",
parameters: z.object({}),
hidden: false,
defaultInactive: false,
deferrable: false,
async execute(_id, _params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return { content: [{ type: "text", text: "Done" }], details: {} };
},
onSession(event, ctx) {
// reason: start|switch|branch|tree|shutdown
},
renderCall(args, options, theme) {
// optional TUI render
},
renderResult(result, options, theme, args) {
// optional TUI render
},
});
tool_call/tool_result intercept all tools once the registry is wrapped in sdk.ts, including built-ins and extension/custom tools. ToolDefinition also supports optional hidden, defaultInactive, deferrable, approval, mcpServerName, mcpToolName, renderCall, and renderResult fields.
UI integration points
ctx.ui implements the ExtensionUIContext interface. Support differs by mode.
Interactive mode (extension-ui-controller.ts)
Supported:
- dialogs:
select,confirm,input,editor - input editing:
setEditorText,getEditorText,pasteToEditor,editor - autocomplete stacking:
addAutocompleteProvider(factory)wraps the built-in editor provider (factories apply in registration order and re-apply on every slash-command refresh) - terminal title and working message (
setTitle,setWorkingMessage) - notifications/status/editor text/terminal input/custom overlays
- theme listing/loading by name (
setThemesupports string names) - tools expanded toggle
Current no-op methods in this controller:
setFootersetHeader
setEditorComponent is wired to the live editor (ctx.setEditorComponent(factory)). setWidget renders real widget components above or below the editor via setHookWidget(...) (placement: "aboveEditor" | "belowEditor"; string-array content capped at 10 lines).
RPC mode (rpc-mode.ts)
ctx.ui is backed by RPC extension_ui_request events:
- dialog methods (
select,confirm,input,editor) round-trip to client responses - fire-and-forget methods emit requests (
notify,setStatus,setWidgetfor string arrays,setEditorText;setTitleemits only whenVEYYON_RPC_EMIT_TITLE=1)
Unsupported/no-op in RPC implementation:
onTerminalInputcustomsetFooter,setHeader,setEditorComponent,addAutocompleteProvidersetWorkingMessage- theme switching/loading (
setThemereturns failure) - tool expansion controls are inert
Print/headless/subagent paths
When no UI context is supplied to runner init, ctx.hasUI is false and methods are no-op/default-returning.
ACP mode
ACP installs an elicitation-bridged UI context (createAcpExtensionUiContext in acp-agent.ts). ctx.hasUI is true while only select/confirm/input round-trip (as ACP elicitations; defaults are returned when the client lacks the elicitation.form capability). The non-elicitation surface (widgets, editor, theming, terminal input, autocomplete stacking) is stubbed no-op.
Session and state patterns
For durable extension state:
- Persist with
pi.appendEntry(customType, data). - Rebuild state from
ctx.sessionManager.getBranch()onsession_start,session_branch,session_tree. - Keep tool result
detailsstructured when state should be visible/reconstructible from tool result history.
Example reconstruction pattern:
pi.on("session_start", async (_event, ctx) => {
let latest;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "custom" && entry.customType === "my-state") {
latest = entry.data;
}
}
// restore from latest
});
Rendering extension points
Custom message renderer
pi.registerMessageRenderer("my-type", (message, { expanded }, theme) => {
// return pi-tui Component
});
Used by interactive rendering when custom messages are displayed.
Return undefined to decline and let veyyon draw its built-in card for that
message. If your renderer throws instead, veyyon draws the built-in card and adds
a notice row to it:
✗ custom message "my-type" renderer threw: cannot read properties of undefined — showing the default card; fix or remove the renderer
The failure is also written to the log, so a renderer that breaks only on certain payloads is visible in the transcript rather than silently replaced.
Assistant thinking renderer
import { Container, Text } from "@veyyon/tui";
pi.registerAssistantThinkingRenderer((context, theme) => {
const container = new Container();
container.addChild(new Text(theme.fg("dim", `thinking chars: ${context.text.length}`), 1, 0));
return container;
});
Used by interactive rendering to add display-only supplemental UI below each visible assistant thinking block. The renderer receives the already-visible thinking text, content/thinking indexes, theme, and a requestRender() callback for async renderers. All registered renderers that return a component are appended in registration order. Renderers must not mutate messages; the original thinking block remains the provider/session source of truth.
Tool call/result renderer
Provide renderCall / renderResult on registerTool definitions for custom tool visualization in TUI.
Constraints and pitfalls
- Runtime actions are unavailable during extension load.
tool_callerrors block execution (fail-closed).- Command name conflicts with built-ins are skipped with diagnostics.
- Reserved shortcuts are ignored (
ctrl+c,ctrl+d,ctrl+z,ctrl+k,ctrl+p,ctrl+l,ctrl+o,ctrl+t,ctrl+g,ctrl+q,alt+m,shift+tab,shift+ctrl+p,alt+enter,escape,enter). - Treat
ctx.reload()as terminal for the current command handler frame.
Extensions vs hooks vs custom-tools
Use the right surface:
- Extensions (
src/extensibility/extensions/*): unified system (events + tools + commands + renderers + provider registration). - Hooks (
src/extensibility/hooks/*): separate legacy event API. - Custom-tools (
src/extensibility/custom-tools/*): tool-focused modules; when loaded alongside extensions they are adapted and still pass through extension interception wrappers.
If you need one package that owns policy, tools, command UX, and rendering together, use extensions.
Extensions authoring
name: authoring-extensions description: Use when creating a new veyyon extension. Covers ExtensionAPI, factory signature, tool/command/event registration, and local-dev testing.
Authoring Extensions
Extensions are the primary way to add capabilities to Veyyon. A single extension module can register tools the LLM can call, slash commands users can invoke, and event handlers that run throughout the session lifecycle, all from one TypeScript file.
Minimum viable extension
import type { ExtensionAPI } from "@veyyon/coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("My extension loaded!", "info");
});
}
That is a working extension. Drop it into ~/.veyyon/profiles/default/agent/extensions/hello.ts (or the active profile’s agent dir) and restart veyyon to see the notification.
Full example
The following extension registers a slash command, a tool, and a session-start hook:
import type { ExtensionAPI } from "@veyyon/coding-agent";
export default function myExtension(pi: ExtensionAPI) {
const z = pi.zod;
// Runs once when the session loads
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Session ready in ${ctx.cwd}`, "info");
});
// Slash command: /greet
pi.registerCommand("greet", {
description: "Send a greeting into the conversation",
handler: async (args, ctx) => {
const name = args.trim() || "world";
pi.sendMessage(
{
customType: "greeting",
content: `Hello, ${name}!`,
display: true,
attribution: "user",
},
{ triggerTurn: false }
);
ctx.ui.notify(`Greeted ${name}`, "info");
},
});
// LLM-callable tool
pi.registerTool({
name: "word_count",
label: "Word Count",
description: "Count the words in a string",
parameters: z.object({
text: z.string().describe("Text to count"),
}),
async execute(_id, params, _signal, _onUpdate, _ctx) {
const count = params.text.split(/\s+/).filter(Boolean).length;
return {
content: [{ type: "text", text: String(count) }],
details: { count },
};
},
});
}
Discovery paths
veyyon loads extension modules from these sources:
-
The active profile’s native locations, discovered through the capability system:
~/.veyyon/profiles/default/agent/extensions/- legacy extension paths listed in
~/.veyyon/profiles/default/agent/settings.json#extensions
A working tree’s
.veyyon/extensions/or.veyyon/settings.json#extensionsis not read: a checked-in file must not hand the agent executable modules. -
Installed plugins under
~/.veyyon/profiles/default/plugins/node_modules(veyyon plugin installnpm/git/marketplace specs, orveyyon plugin link) via theirveyyon.extensionsmanifests (legacyomp.extensions/pi.extensionsstill accepted). Marketplace installs are symlinked into the samenode_modulestree, so theirveyyon.extensionsmanifests load extension modules too. -
Explicit configured paths passed by the CLI (
veyyon --extension ./my-ext.ts, also-e;--hookis treated as an alias) and by theextensions:setting in config.
The runtime de-duplicates by resolved absolute path, first seen wins.
When a path points to a directory, veyyon resolves the entry point in this order:
package.jsonwithveyyon.extensions(legacyomp.extensions/pi.extensions) fieldindex.tsindex.js
When scanning an extensions/ directory, veyyon also loads direct *.ts/*.js files and one-level subdirectories that have index.ts, index.js, or a manifest.
Extension packages can also bundle sibling capability directories. When a package is loaded through extensions: or --extension/-e, the veyyon-plugins provider discovers its skills/, hooks/pre|post/, tools/, commands/, rules/, prompts/, and .mcp.json.
package.json manifest
To package an extension as an installable plugin, add a veyyon field to package.json (legacy omp / pi keys still load; veyyon wins when several are present):
{
"name": "my-veyyon-extension",
"veyyon": {
"extensions": ["./src/main.ts"]
}
}
The legacy pi key is also accepted for backwards compatibility:
{
"pi": {
"extensions": ["./index.ts"]
}
}
Multiple entry points are supported:
{
"veyyon": {
"extensions": ["./src/safety.ts", "./src/tools.ts"]
}
}
Registering commands
pi.registerCommand("my-cmd", {
description: "What the command does",
handler: async (args, ctx) => {
// args: everything the user typed after /my-cmd
// ctx: ExtensionCommandContext, includes ctx.ui, ctx.cwd, session controls
ctx.ui.notify("Running!", "info");
await ctx.waitForIdle();
await ctx.newSession();
},
});
ExtensionCommandContext session-control methods (safe to call from commands only):
| Method | Effect |
|---|---|
waitForIdle() | Wait for the agent to finish streaming |
newSession(opts?) | Open a fresh session |
switchSession(path) | Switch to an existing session file |
branch(entryId) | Fork from a specific history entry |
navigateTree(id, opts?) | Jump to a different point in the session tree |
reload() | Reload the session runtime |
compact(opts?) | Compact the current context |
Registering tools
Tools are called by the LLM. Parameters use Zod schemas, available at pi.zod:
const z = pi.zod;
pi.registerTool({
name: "search_notes", // snake_case, unique
label: "Search Notes", // human-readable label for TUI
description: "Full-text search through project notes",
parameters: z.object({
query: z.string().describe("Search query"),
limit: z.number().default(10).describe("Max results").optional(),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
// ... do work ...
return {
content: [{ type: "text", text: `Found N results for "${params.query}"` }],
details: { query: params.query, count: 0 },
};
},
});
Subscribing to events
pi.on("tool_call", async (event, ctx) => {
// event.toolName, event.input, event.toolCallId
if (event.toolName !== "bash") return;
const command = String((event.input as { command?: unknown }).command ?? "");
if (command.includes("rm -rf /")) {
return { block: true, reason: "Blocked by safety policy" };
}
});
pi.on("turn_end", async (_event, ctx) => {
ctx.ui.setStatus("tokens", `~${ctx.getContextUsage()?.tokens ?? "?"} tokens`);
});
pi.on("session_stop", async (event) => {
if (event.stop_hook_active) return;
return { continue: true, additionalContext: `Review final status after turn ${event.turn_id}.` };
});
Full event catalog: see extension authoring guide.
Extension vs hook: when to use which
| Need | Use |
|---|---|
| Tools + commands + events in one module | Extension (ExtensionAPI) |
| Pure event interception (policy, redaction) | Extension or Hook (both work; extension is preferred) |
| Legacy hook module already exists | Hook (HookAPI from @veyyon/coding-agent/extensibility/hooks) |
| Registering a provider, shortcut, or CLI flag | Extension only |
| Shipping as a marketplace plugin | Extension (use package.json manifest) |
Extensions are a strict superset of hooks. New authoring should use ExtensionAPI.
Debugging
veyyon writes structured logs to a rotating file under the active profile logs dir (~/.veyyon/profiles/<name>/logs/; debug level is always on; nothing is written to the console, which would corrupt the TUI). Tail today’s log to see extension load diagnostics:
tail -f ~/.veyyon/profiles/default/logs/veyyon.$(date +%F).log
Failed extension loads are logged with their path and error. Loaded extensions may also emit their own debug logs via pi.logger.
To temporarily disable a specific extension module by name without removing the file:
# ~/.veyyon/profiles/default/agent/config.yml
disabledExtensions:
- extension-module:my-ext
The derived name is the filename stem (or directory name for index.ts-style entries): /path/to/my-ext.ts → my-ext.
Important constraints
- Do not call runtime actions during load. Methods like
pi.sendMessage()throwExtensionRuntimeNotInitializedErrorif called synchronously during module evaluation (before a session is active). Register handlers/tools/commands during load; perform runtime actions only from event handlers, tools, or commands. tool_callerrors are fail-closed. If atool_callhandler throws, the tool is blocked.- Command names must not clash with built-ins. Conflicts are skipped with a diagnostic log.
- Reserved shortcuts are ignored (
ctrl+c,ctrl+d,ctrl+z,ctrl+k,ctrl+p,ctrl+l,ctrl+o,ctrl+t,ctrl+g,ctrl+q,alt+m,shift+tab,shift+ctrl+p,alt+enter,escape,enter).
Further reading
docs/handbook/src/features/extensions.md: runtime internals and full API surface referencedocs/internal/extension-loading.md: detailed path resolution rulesdocs/handbook/src/reference/hooks.md: hook subsystem internalspackages/coding-agent/examples/hello-extension/: complete working example
Custom Tools
Custom tools are model-callable functions that plug into the same tool execution pipeline as built-in tools.
A custom tool is a TypeScript/JavaScript module that exports a factory. The factory receives a host API (CustomToolAPI) and returns one tool or an array of tools.
What this is (and is not)
- Custom tool: callable by the model during a turn (
execute+ Zod parameter schema). - Extension: lifecycle/event framework that can register tools and intercept/modify events.
- Hook: TypeScript module that registers handlers with
pi.on(...)(same event bus as extensions;--hookaliases--extension). - Skill: static guidance/context package, not executable tool code.
If you need the model to call code directly, use a custom tool.
Integration paths in current code
There are two active integration styles:
-
SDK-provided custom tools (
options.customTools)- Wrapped into agent tools via
CustomToolAdapteror extension wrappers. - Always included in the initial active tool set in SDK bootstrap.
- Wrapped into agent tools via
-
Filesystem-discovered modules via loader API (
discoverAndLoadCustomTools/loadCustomTools)- Exposed as library APIs in
src/extensibility/custom-tools/loader.ts. - Host code can call these to discover and load tool modules from config/provider/plugin paths.
- Exposed as library APIs in
Model tool call flow
LLM tool call
│
▼
Tool registry (built-ins + custom tool adapters)
│
▼
CustomTool.execute(toolCallId, params, onUpdate, ctx, signal)
│
├─ onUpdate(...) -> streamed partial result
└─ return result -> final tool content/details
Discovery locations (loader API)
discoverAndLoadCustomTools(configuredPaths, cwd, builtInToolNames) merges:
- Capability providers (
toolCapability), all user-level (a working tree never contributes tools):- Native Veyyon config (
~/.veyyon/profiles/<name>/agent/tools) - Claude config (
~/.claude/tools) - Codex config (
~/.codex/tools) - Claude marketplace plugin cache provider
- Native Veyyon config (
- Installed plugin manifests (
~/.veyyon/profiles/<profile>/plugins/node_modules/*via plugin loader; a project root<anchor>/.veyyon/pluginsis enumerated the same way) - Explicit configured paths passed to the loader
Important behavior
- Duplicate resolved paths are deduplicated.
- Tool name conflicts are rejected against built-ins and already-loaded custom tools.
.mdand.jsonfiles are discovered as tool metadata by some providers, but the executable module loader rejects them as runnable tools.- Relative configured paths are resolved from
cwd;~is expanded.
Module contract
A custom tool module must export a function (default export preferred):
import type { CustomToolFactory } from "@veyyon/coding-agent";
const factory: CustomToolFactory = (pi) => ({
name: "repo_stats",
label: "Repo Stats",
description: "Counts tracked TypeScript files",
parameters: pi.zod.object({
glob: pi.zod.string().optional().default("**/*.ts"),
}),
async execute(toolCallId, params, onUpdate, ctx, signal) {
onUpdate?.({
content: [{ type: "text", text: "Scanning files..." }],
details: { phase: "scan" },
});
const result = await pi.exec(
"git",
["ls-files", params.glob ?? "**/*.ts"],
{ signal, cwd: pi.cwd },
);
if (result.killed) {
throw new Error("Scan was cancelled");
}
if (result.code !== 0) {
throw new Error(result.stderr || "git ls-files failed");
}
const files = result.stdout.split("\n").filter(Boolean);
return {
content: [{ type: "text", text: `Found ${files.length} files` }],
details: { count: files.length, sample: files.slice(0, 10) },
};
},
onSession(event) {
if (event.reason === "shutdown") {
// cleanup resources if needed
}
},
});
export default factory;
Schemas are authored with Zod (pi.zod) and flow through the shared validation/wire pipeline.
Factory return type:
CustomToolCustomTool[]Promise<CustomTool | CustomTool[]>
API surface passed to factories (CustomToolAPI)
From types.ts and loader.ts:
cwd: host working directoryexec(command, args, options?): process execution helperui: UI context (can be no-op in headless modes)hasUI:falsein non-interactive flowslogger: shared file loggertypebox: self-contained compatibility shim for legacy TypeBox-style schemas (legacy/compat, preferarktypeorzodfor new tools)zod: injectedzod/v4module (canonical for new schemas)pi: injected@veyyon/coding-agentexportspushPendingAction(action): register a preview action for hiddenresolvetool (docs/internal/resolve-tool-runtime.md) Loader starts with a no-op UI context and requires host code to callsetUIContext(...)when real UI is ready.
Execution contract and typing
CustomTool.execute signature:
execute(toolCallId, params, onUpdate, ctx, signal);
paramsis statically typed from your Zod/TypeBox schema viaStatic<TParams>.- Runtime argument validation happens before execution in the agent loop.
onUpdateemits partial results for UI streaming.ctxincludessessionManager,modelRegistry, currentmodel,isIdle(),hasQueuedMessages(),abort(), and optionalsettings,fetch, andautoApprove.signalcarries cancellation.
An extension tool registered with pi.registerTool takes the same five arguments
in a different order, with the signal third:
execute(toolCallId, params, signal, onUpdate, ctx);
Use the order that belongs to the API you are writing against. Copying one into
the other place raises no error at the call site and none at runtime either: the
arguments still arrive, so ctx is the update callback and the first
ctx.sessionManager you touch is undefined.
CustomToolAdapter bridges this to the agent tool interface and forwards calls in the correct argument order.
Tool definitions may also declare strict, hidden, deferrable, mcpServerName, mcpToolName, approval, and formatApprovalDetails.
How tools are exposed to the model
- Tools are wrapped into
AgentToolinstances (CustomToolAdapteror extension wrappers). - They are inserted into the session tool registry by name.
- In SDK bootstrap, custom and extension-registered tools are force-included in the initial active set.
- CLI
--toolscurrently validates only built-in tool names; custom tool inclusion is handled through discovery/registration paths and SDK options.
Rendering hooks
Optional rendering hooks:
renderCall(args, options, theme)renderResult(result, options, theme, args?)
Runtime behavior in TUI:
- If hooks exist, tool output is rendered inside a
Boxcontainer. renderResultreceives{ expanded, isPartial, spinnerFrame? }.
If a rendering hook throws, veyyon catches it so the session keeps running, and then reports it where your card would have been:
✗ tool "widget" result renderer threw: payload has no rows — showing raw output; fix or remove the renderer
The notice states which hook failed, because renderCall and renderResult fail
independently, and it shows what you are looking at instead: the tool label alone
for a failed renderCall, the raw text output for a failed renderResult. When
there is no raw output to fall back to, it reports that too rather than implying
output you cannot see. The failure is also written to the log.
Returning undefined from a hook is different: that is how you decline to draw
for a particular call, and it renders the default with no notice.
Session/state handling
Optional onSession(event, ctx) receives session lifecycle events, including:
start,switch,branch,tree,shutdownauto_compaction_start,auto_compaction_endauto_retry_start,auto_retry_endttsr_triggered,todo_reminder
Use ctx.sessionManager to reconstruct state from history when branch/session context changes.
Failures and cancellation semantics
Synchronous/async failures
- Throwing (or rejected promises) in
executeis treated as tool failure. - Agent runtime converts failures into tool result messages with
isError: trueand error text content. - With extension wrappers,
tool_resulthandlers can further rewrite content/details and even override error status.
Cancellation
- Agent abort propagates through
AbortSignaltoexecute. - Forward
signalto subprocess work (pi.exec(..., { signal })) for cooperative cancellation. ctx.abort()lets a tool request abort of the current agent operation.
onSession errors
onSessionerrors are caught and logged as warnings; they do not crash the session.
Real constraints to design for
- Tool names must be globally unique in the active registry.
- Prefer deterministic, schema-shaped outputs in
detailsfor renderer/state reconstruction. - Guard UI usage with
pi.hasUI. - Treat
.md/.jsonin tool directories as metadata, not executable modules.
Marketplace plugin system
Discover, install, and manage plugins from Git, local, or catalog sources. Catalog layout is compatible with the Claude Code plugin registry format.
Quick start
veyyon plugin marketplace add anthropics/claude-plugins-official
veyyon plugin install wordpress.com@claude-plugins-official
The interactive /marketplace TUI was removed from Veyyon. Manage marketplaces and marketplace plugins through the veyyon plugin CLI. Inside the TUI, /plugins list shows installed npm/link plugins.
Concepts
A marketplace is a Git repository (or local directory) containing a catalog file at .veyyon-plugin/marketplace.json (preferred) or .claude-plugin/marketplace.json (Claude Code-compatible fallback). The catalog lists available plugins with their sources, descriptions, and metadata.
A plugin is a directory containing Claude/Veyyon plugin content such as skills, commands, agents, hooks, tools, MCP servers, or LSP servers. Extension modules (package.json veyyon.extensions entry points; legacy omp/pi keys still accepted) load from marketplace installs just as they do from npm-installed or veyyon plugin linked plugins, because the install symlinks the cached plugin into the runtime node_modules tree. Plugins are identified by name@marketplace (e.g. code-review@claude-plugins-official).
Scopes: marketplace plugins can be installed at two scopes:
- user (default) – available in all projects under the active profile, stored in
~/.veyyon/profiles/<profile>/plugins/installed_plugins.json(default profile:profiles/default/plugins/) - project – available only in the active project, stored in the nearest project
.veyyon/plugins/installed_plugins.json
Enabled project-scoped installs shadow enabled user-scoped installs of the same plugin. A disabled project install does not shadow the user install.
Commands
Marketplace management:
veyyon plugin marketplace add <source>
veyyon plugin marketplace remove <name>
veyyon plugin marketplace update [name]
veyyon plugin marketplace list
Plugin operations:
veyyon plugin discover [marketplace]
veyyon plugin install [--force] [--scope user|project] name@marketplace
veyyon plugin uninstall [--scope user|project] name@marketplace
veyyon plugin upgrade [--scope user|project] [name@marketplace]
veyyon plugin enable [--scope user|project] name@marketplace
veyyon plugin disable [--scope user|project] name@marketplace
Marketplace sources
When you run veyyon plugin marketplace add <source>, the system classifies the source:
| Source format | Type | Example |
|---|---|---|
owner/repo | GitHub shorthand | anthropics/claude-plugins-official |
https://...*.json | Direct catalog URL | https://example.com/marketplace.json |
https://... / http://... | Git repository unless the URL path ends in .json | https://github.com/org/repo |
git@... / ssh://... | Git repository | [email protected]:org/repo.git |
./path or ~/path or an absolute path | Local directory | ./my-marketplace |
Git and local sources must contain a catalog at .veyyon-plugin/marketplace.json (preferred) or .claude-plugin/marketplace.json (Claude Code-compatible fallback). Direct catalog URLs cache only the JSON catalog; plugins in URL-sourced catalogs cannot use relative string sources like "./plugins/foo".
Catalog format (marketplace.json)
A marketplace catalog lives at .veyyon-plugin/marketplace.json in the repository root. When veyyon is the only intended consumer, prefer this path. To remain Claude Code-compatible (veyyon loads the same shape from either path), publish at .claude-plugin/marketplace.json instead, veyyon uses it as a fallback when .veyyon-plugin/marketplace.json is absent. A repository may ship both: veyyon reads the .veyyon-plugin/ copy, Claude Code reads the .claude-plugin/ copy. Same catalog format either way:
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "my-marketplace",
"owner": {
"name": "Your Name",
"email": "[email protected]"
},
"metadata": {
"description": "A collection of plugins",
"version": "1.0.0",
"pluginRoot": "plugins"
},
"plugins": [
{
"name": "my-plugin",
"description": "What this plugin does",
"source": "./my-plugin",
"category": "development",
"homepage": "https://github.com/you/my-plugin"
}
]
}
Required fields
| Field | Description |
|---|---|
name | Marketplace name. Lowercase alphanumeric, hyphens, and dots. Must start and end with alphanumeric. Max 64 chars. |
owner.name | Marketplace owner name |
plugins | Array of plugin entries |
Top-level metadata.description, metadata.version, and metadata.pluginRoot are optional. When metadata.pluginRoot is set, it is prepended to relative plugin source paths.
Plugin entry fields
| Field | Required | Description |
|---|---|---|
name | yes | Plugin name (same rules as marketplace name) |
source | yes | Where to find the plugin (see below) |
description | no | Short description |
version | no | Version string; install version falls back to plugin manifest, source SHA, then 0.0.0 |
author | no | { name, email? } |
homepage | no | URL |
repository | no | Repository URL/string |
license | no | License string |
keywords | no | Array of string keywords |
category | no | Category string (e.g. development, productivity, security) |
tags | no | Array of string tags |
strict | no | Boolean |
commands | no | Slash commands provided |
agents | no | Agents provided |
hooks | no | Hook definitions |
mcpServers | no | MCP server definitions |
lspServers | no | LSP server definitions or path; copied to .lsp.json on install |
Plugin source formats
The source field supports these formats. String sources must start with ./ and are resolved inside the marketplace root, after optional metadata.pluginRoot is prepended:
Relative path (within the marketplace repo):
"source": "./my-plugin"
Git repository URL:
"source": {
"source": "url",
"url": "https://github.com/org/repo.git",
"sha": "abc123..."
}
GitHub shorthand:
"source": {
"source": "github",
"repo": "org/repo",
"ref": "main",
"sha": "abc123..."
}
Git subdirectory (monorepo):
"source": {
"source": "git-subdir",
"url": "https://github.com/org/monorepo.git",
"path": "plugins/my-plugin",
"ref": "main",
"sha": "abc123..."
}
npm package (parsed but not installable yet):
"source": {
"source": "npm",
"package": "@scope/my-plugin",
"version": "1.0.0"
}
Current installer behavior rejects npm marketplace sources with npm plugin sources are not yet supported; use relative, GitHub, URL, or git-subdir sources.
On-disk layout
~/.veyyon/
marketplaces.json # Registry of added marketplaces
profiles/<profile>/plugins/
installed_plugins.json # User-scoped marketplace plugins (version: 2)
cache/
marketplaces/<name>/ # Cached marketplace clone/catalog
plugins/<marketplace>___<plugin>___<version>/ # Cached plugin directories
<project>/.veyyon/
plugins/
installed_plugins.json # Project-scoped marketplace plugins (version: 2)
Naming rules
Marketplace and plugin names must:
- Start and end with a lowercase letter or digit
- Contain only lowercase letters, digits, hyphens, and dots
- Be at most 64 characters
Plugin IDs (name@marketplace) must be at most 128 characters total.
Valid examples: my-plugin, code-review, wordpress.com, ai-firstify
Invalid examples: -bad, bad-, .bad, Bad, under_score
Marketplace authoring
name: authoring-marketplaces description: Use when creating a new veyyon marketplace. Covers marketplace.json schema, source types, install commands, and publishing.
Authoring Marketplaces
A marketplace is a Git repository (or local directory) that contains a catalog file at either .veyyon-plugin/marketplace.json (preferred for veyyon-specific catalogs) or .claude-plugin/marketplace.json (Claude Code-compatible; used as the fallback). Anyone can author one. Users add it with veyyon plugin marketplace add owner/repo and then install individual plugins from it.
Minimum viable marketplace
my-marketplace/
.claude-plugin/
marketplace.json
plugins/
my-plugin/
skills/
my-skill/
SKILL.md
{
"name": "my-marketplace",
"owner": { "name": "Your Name" },
"plugins": [
{
"name": "my-plugin",
"description": "What it does",
"source": "./plugins/my-plugin"
}
]
}
Push to GitHub. Users install with:
veyyon plugin marketplace add your-github-username/my-marketplace
veyyon plugin install my-plugin@my-marketplace
marketplace.json schema
The catalog file lives at either .veyyon-plugin/marketplace.json or .claude-plugin/marketplace.json in the repository root. veyyon prefers the .veyyon-plugin/ path and falls back to the Claude path; a repository may publish both to expose tool-specific catalogs from a single source tree.
Top-level fields
| Field | Required | Description |
|---|---|---|
name | yes | Marketplace name. Lowercase alphanumeric, hyphens, dots. Must start and end with alphanumeric. Max 64 chars. |
owner | yes | Object with at minimum owner.name (string) |
owner.name | yes | Marketplace owner name |
owner.email | no | Owner contact email |
plugins | yes | Array of plugin entries (see below) |
metadata.description | no | Short description of the marketplace |
metadata.version | no | Catalog metadata version string |
metadata.pluginRoot | no | String prepended to all relative plugin source paths |
| extra top-level fields | no | Preserved by the parser but not used by marketplace install/runtime logic |
Plugin entry fields
| Field | Required | Description |
|---|---|---|
name | yes | Plugin name (same naming rules as marketplace name) |
source | yes | Where to find the plugin, string or object (see source types below) |
description | no | Short plugin description |
version | no | Version string |
author | no | { name, email? } |
homepage | no | URL |
category | no | e.g. development, productivity, security |
tags / keywords | no | Arrays of string tags/keywords |
repository | no | Repository URL |
license | no | License string |
strict | no | Boolean plugin metadata flag |
commands, agents, hooks, mcpServers, lspServers | no | Capability metadata used by plugin tooling and selectors |
Full catalog example
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "acme-plugins",
"owner": {
"name": "Acme Corp",
"email": "[email protected]"
},
"metadata": {
"description": "Official Acme plugins for Veyyon"
},
"plugins": [
{
"name": "acme-linter",
"description": "Enforce Acme coding standards",
"category": "development",
"source": "./plugins/linter"
},
{
"name": "acme-deploy",
"description": "One-command deploy to Acme cloud",
"category": "devops",
"source": {
"source": "github",
"repo": "acme-corp/veyyon-deploy-plugin",
"ref": "main"
}
}
]
}
Plugin source types
1. Relative path string
Points to a subdirectory inside the marketplace repository itself. Must start with ./.
"source": "./plugins/my-plugin"
The path is resolved relative to the marketplace repository root. Path traversal outside the repo root is rejected.
Use metadata.pluginRoot to avoid repeating a common prefix:
{
"metadata": { "pluginRoot": "./plugins" },
"plugins": [
{ "name": "plugin-a", "source": "./plugin-a" },
{ "name": "plugin-b", "source": "./plugin-b" }
]
}
2. Git URL
A full Git repository URL. Optionally pin to a branch/tag (ref) or exact commit (sha):
"source": {
"source": "url",
"url": "https://github.com/org/my-plugin.git",
"ref": "main",
"sha": "a1b2c3d4..."
}
3. GitHub shorthand
Shorthand for GitHub repositories. Functionally equivalent to a Git URL but more concise:
"source": {
"source": "github",
"repo": "org/my-plugin",
"ref": "v2.1.0",
"sha": "a1b2c3d4..."
}
4. Git subdirectory (monorepo)
For plugins living inside a subdirectory of a larger repository. url accepts a full HTTPS URL or a GitHub owner/repo shorthand:
"source": {
"source": "git-subdir",
"url": "https://github.com/org/monorepo.git",
"path": "packages/my-plugin",
"ref": "main",
"sha": "a1b2c3d4..."
}
The path must resolve inside the cloned repository, directory escape is rejected.
5. NPM package
Declares the plugin as an npm package. version is optional:
"source": {
"source": "npm",
"package": "@acme/veyyon-plugin",
"version": "1.2.0"
}
Note: npm plugin sources are declared in the schema but installation support is not yet fully implemented. Use Git-based sources for plugins that need to work today.
Plugin structure
A plugin directory (regardless of source type) ships its content in conventional locations, all optional:
my-plugin/
skills/<name>/SKILL.md ← skills
commands/*.md ← slash commands
agents/*.md ← subagent definitions
hooks/pre/, hooks/post/ ← hooks
tools/ ← custom tools
.mcp.json ← MCP server definitions
package.json ← optional; its version is a fallback when the catalog entry has no version
README.md ← recommended: description + usage
Note: extension modules declared via
package.jsonveyyon.extensions(legacyomp/pi) load from marketplace installs exactly as they do from npm-installed orveyyon plugin linked plugins: the install symlinks the cached plugin into the runtimenode_modulestree that the extension loader enumerates.
Install command
veyyon plugin install name@marketplace-name
veyyon plugin install --force name@marketplace-name # reinstall
veyyon plugin install --scope project name@marketplace # project-scoped
Add the marketplace first:
veyyon plugin marketplace add owner/repo
veyyon plugin install name@marketplace-name
Scope behavior:
- user (default): installed in
~/.veyyon/profiles/default/plugins/installed_plugins.json, available in all projects - project: installed in
<project>/.veyyon/plugins/installed_plugins.json, available only in that project
Project-scoped installs shadow user-scoped installs of the same plugin name.
Naming rules
Marketplace names and plugin names must:
- Contain only lowercase letters, digits, hyphens (
-), and dots (.) - Start and end with a lowercase letter or digit
- Be at most 64 characters
Plugin IDs (name@marketplace) must be at most 128 characters total.
Valid: my-plugin, code-review, acme.tools, ai-v2
Invalid: -bad-start, bad-end-, .dot-start, Under_score, HAS_CAPS
Publishing workflow
- Create
marketplace.jsonat.veyyon-plugin/marketplace.json(veyyon-only) or.claude-plugin/marketplace.json(shared with Claude Code) in a new Git repo. - Add plugin entries pointing to subdirectories (or external sources).
- Push to GitHub.
- Share the
owner/repostring. Users add it withveyyon plugin marketplace add owner/repo. - When you update the catalog, users run
veyyon plugin marketplace update your-marketplace-nameto pull the latest.
To test locally before publishing:
veyyon plugin marketplace add ./path/to/my-marketplace
Local path sources also accept ~/ and absolute paths.
Further reading
docs/handbook/src/features/marketplace.md: marketplace system internals, on-disk layout, command referencedocs/handbook/src/features/extensions-authoring.md: how to author the extension modules inside pluginspackages/coding-agent/examples/mini-marketplace/: minimal working marketplace example
Hooks
A hook lets you run your own code at a moment in the session: before a tool call, after a message,
when a turn ends. You use one to enforce policy (block a dangerous command), add context, or record
what happened. The example below rejects any bash command containing rm -rf.
A hook is a TypeScript module. You put it under a hook path in your profile (for example
~/.veyyon/profiles/<name>/agent/hooks/), and Veyyon loads it through the extension runner. A
repository cannot ship a hook: a .veyyon/hooks/ directory inside a working tree is not read. The
module default-exports a factory function that registers handlers with pi.on(...), one handler per
event you care about.
CLI: --hook is an alias for --extension (paths merge into extension loading).
Full API and event list: repository docs/handbook/src/reference/hooks.md and packages/coding-agent/src/extensibility/hooks/.
Module shape
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function hook(pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (
event.toolName === "bash" &&
String(event.input.command ?? "").includes("rm -rf")
) {
return { block: true, reason: "blocked by policy" };
}
});
}
The factory can:
- register handlers with
pi.on(...) - send custom messages with
pi.sendMessage(...) - persist non-LLM state with
pi.appendEntry(...) - register slash commands with
pi.registerCommand(...) - register message renderers with
pi.registerMessageRenderer(...) - run shell commands with
pi.exec(...)
Discovery
Hook/extension paths are resolved as absolute, ~-expanded, or relative to cwd. Discovery loads capability-registered modules, importable .ts/.js factories, plugin extension entry points, and explicit paths.
Lifecycle (extension bus)
Handlers attach to the runtime event bus used by the extension runner (tool call, session, compaction, and related events as defined in types.ts). Exact event names and payloads are in packages/coding-agent/src/extensibility/hooks/types.ts and docs/handbook/src/reference/hooks.md.
Typical uses
- Block or annotate specific tools before they run
- Inject policy text when a session starts
- Audit tool usage outside the TUI
- Register your own slash commands
Related
- Repository
docs/handbook/src/reference/hooks.md
Hooks authoring
name: authoring-hooks description: Use when creating a new veyyon hook. Covers HookAPI, event catalog, blocking/overriding tool calls, and context modification.
Authoring Hooks
Hooks are event-driven interceptors that run alongside the agent loop. They are best used for cross-cutting concerns: safety policy, secret redaction, context pruning, audit logging. A hook module registers handlers via pi.on(event, handler) and can block tool execution, override tool output, or rewrite the message context before each LLM call.
Relationship to extensions: The hook subsystem (
HookAPI) is the legacy API. The extension runner now handles everything hooks can do plus more.ExtensionAPIsupports the hook event model plus extension-only events. UseExtensionAPIfor new work; useHookAPIonly if you are maintaining an existing hook module.
Factory signature
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function myHook(veyyon: HookAPI): void {
veyyon.on("tool_call", async (event, ctx) => {
// intercept every tool call
});
}
The default export must be a function (not a class). It receives a HookAPI instance and registers all handlers when called; the loader awaits its return value, so an async factory is awaited before the hook is considered loaded.
Alternatively, using ExtensionAPI (preferred):
import type { ExtensionAPI } from "@veyyon/coding-agent";
export default function myExtension(pi: ExtensionAPI): void {
pi.on("tool_call", async (event, ctx) => { /* ... */ });
}
Event catalog
Tool lifecycle
| Event | Fires | Can return |
|---|---|---|
tool_call | Before every tool execution | { block?: boolean; reason?: string } |
tool_result | After every tool execution | { content?; details?; isError?: boolean } |
Session lifecycle
| Event | Fires | Can return |
|---|---|---|
session_start | On initial session load | n/a |
session_before_switch | Before session switch | { cancel?: boolean } |
session_switch | After session switch | n/a |
session_before_branch | Before session branch | { cancel?: boolean; skipConversationRestore?: boolean } |
session_branch | After session branch | n/a |
session_before_compact | Before compaction | { cancel?: boolean; compaction?: CompactionResult } |
session_compacting | During compaction (inject context) | { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } |
session_compact | After compaction | n/a |
session_before_tree | Before tree navigation | { cancel?: boolean; summary?: { summary: string; details?: unknown } } |
session_tree | After tree navigation | n/a |
session_shutdown | On session shutdown | n/a |
Agent/turn lifecycle
| Event | Fires | Can return |
|---|---|---|
before_agent_start | Before agent starts a turn | { message?: { customType; content; display; details; attribution? } } |
agent_start | Agent streaming starts | n/a |
agent_end | Agent streaming ends | n/a |
turn_start | Start of a user→agent turn | n/a |
turn_end | End of a user→agent turn | n/a |
context | Before each LLM API call | { messages?: Message[] } |
auto_compaction_start | Auto-compaction begins | n/a |
auto_compaction_end | Auto-compaction ends | n/a |
auto_retry_start | Auto-retry begins | n/a |
auto_retry_end | Auto-retry ends | n/a |
ttsr_triggered | A Time-Traveling Stream Rule (TTSR) triggered | n/a |
todo_reminder | Todo reminder fires | n/a |
Extension-only events such as tool_execution_start, tool_execution_update, tool_execution_end, input, user_bash, and user_python require ExtensionAPI.
Pre-tool blocking contract
Return { block: true, reason: "..." } from a tool_call handler to prevent execution:
veyyon.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash") {
const cmd = String(event.input.command ?? "");
if (/\brm\s+-rf\s+\//.test(cmd)) {
return { block: true, reason: "Refusing to delete root filesystem" };
}
}
});
Contract:
- If any handler returns
{ block: true }, execution stops immediately. reasonis returned to the LLM as the tool error text.- If a handler throws, the tool is also blocked (fail-closed).
- Last non-blocking return wins for non-blocking results; first
block: trueshort-circuits.
Post-tool override contract
Return { content, details, isError } from a tool_result handler to patch what the LLM sees:
veyyon.on("tool_result", async (event, ctx) => {
if (event.toolName === "read" && !event.isError) {
const redacted = event.content.map(chunk => {
if (chunk.type !== "text") return chunk;
return {
...chunk,
text: chunk.text.replace(/(?:sk|pk)-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]"),
};
});
return { content: redacted };
}
});
Contract:
- Handlers run in registration order. For
HookAPI, each handler receives the original tool result event, and the last returned override wins. contentreplaces the full content array for the LLM.detailsreplaces the structured details object.isErrorexists on the shared result type, butHookToolWrapperdoes not propagate it into a successful tool result; on a tool failure, the original error is rethrown after handlers complete.- On a tool failure,
tool_resultis still emitted withisError: true.
Context modification contract
Return { messages: [...] } from a context handler to rewrite the message list before each LLM API call:
veyyon.on("context", async (event, ctx) => {
// Remove debug-only custom messages from LLM context
const filtered = event.messages.filter(
msg => !(msg.role === "custom" && msg.customType === "debug-only")
);
return { messages: filtered };
});
Contract:
event.messagesis the current accumulated list.- Handlers run in order; each receives the output of the previous handler.
- Return
undefined(or nothing) to pass messages through unmodified.
Three complete examples
1. rm-rf blocker
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function rmRfBlocker(veyyon: HookAPI): void {
veyyon.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = String(event.input.command ?? "");
if (!/\brm\s+-rf\s+\//.test(cmd)) return;
// Allow if user explicitly confirms (interactive mode only)
if (ctx.hasUI) {
const allow = await ctx.ui.confirm(
"Dangerous command",
`This command deletes from root:\n${cmd}\n\nProceed?`
);
if (allow) return;
}
return { block: true, reason: "rm -rf / blocked by safety policy" };
});
}
2. API-key redactor
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
// Common API-key shapes. Not exhaustive, providers using bespoke formats
// (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.)
// need their own entries.
const SECRET_PATTERNS = [
/\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g,
/\bAKIA[A-Z0-9]{16}\b/g,
/\bghp_[a-zA-Z0-9]{36}\b/g,
// Zhipu / GLM Coding Plan: `<id>.<secret>` (no `sk-` prefix).
/\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g,
/\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g,
];
export default function apiKeyRedactor(veyyon: HookAPI): void {
veyyon.on("tool_result", async (event) => {
if (event.isError) return;
let changed = false;
const redacted = event.content.map(chunk => {
if (chunk.type !== "text") return chunk;
let text = chunk.text;
for (const pattern of SECRET_PATTERNS) {
const next = text.replace(pattern, "[REDACTED]");
if (next !== text) { changed = true; text = next; }
}
return { ...chunk, text };
});
if (changed) return { content: redacted };
});
}
3. Context filter
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function contextFilter(veyyon: HookAPI): void {
veyyon.on("context", async (event) => {
const MAX_TOOL_OUTPUT_CHARS = 8_000;
const trimmed = event.messages.map(msg => {
// Truncate very large tool results to keep context manageable
if (msg.role !== "toolResult") return msg;
const content = msg.content.map(chunk => {
if (chunk.type !== "text" || chunk.text.length <= MAX_TOOL_OUTPUT_CHARS) return chunk;
return {
...chunk,
text: chunk.text.slice(0, MAX_TOOL_OUTPUT_CHARS) + "\n[... truncated by context-filter hook]",
};
});
return { ...msg, content };
});
return { messages: trimmed };
});
}
UI methods in hook context
ctx.ui is a HookUIContext. Available methods:
| Method | Description |
|---|---|
notify(message, type?) | Show an in-app notification |
setStatus(key, text) | Set footer status text (keyed, sorted by key) |
select(title, options) | Show a selection dialog |
confirm(title, message) | Show a yes/no dialog |
input(title, placeholder?) | Show a text input dialog |
editor(title, prefill?, { signal }?, { promptStyle }?) | Show a multi-line editor |
setEditorText(text) | Set the input editor content |
getEditorText() | Get current input editor content |
custom(factory) | Render a custom TUI component |
theme | Current theme object |
Pass { promptStyle: true } as the fourth argument when Enter should submit and Shift+Enter should insert a newline. The default hook editor behavior keeps Enter as newline and submits on the app.message.followUp chord (Ctrl+Q or Ctrl+Enter).
ctx.hasUI is false in headless/print/subagent mode, always guard interactive calls.
Further reading
docs/handbook/src/reference/hooks.md: hook subsystem internals, ordering rules, error propagationdocs/handbook/src/features/extensions.md:ExtensionAPI(superset ofHookAPI)packages/coding-agent/examples/safety-hook/: complete working example
LSP configuration in Veyyon
This guide explains how to configure language servers for the Veyyon coding agent.
Source of truth in code:
- Server config type:
packages/coding-agent/src/lsp/types.ts(ServerConfig) - Config loader:
packages/coding-agent/src/lsp/config.ts - Built-in server definitions:
packages/coding-agent/src/lsp/defaults.json
Auto-detection
When no LSP config file is present, Veyyon auto-detects servers by intersecting two conditions:
- The project directory contains at least one of the server’s
rootMarkers. - The server binary is available: checked in project-local bin directories first (e.g.,
node_modules/.bin/,.venv/bin/), then$PATH.
No configuration is required for common setups. The built-in server list covers most popular languages; see defaults.json for the full set.
Config file locations
Veyyon merges LSP config from multiple files, lowest to highest priority:
| Priority | Location |
|---|---|
| 5 (lowest) | ~/lsp.json, ~/.lsp.json, ~/lsp.yaml, ~/.lsp.yaml, ~/lsp.yml, ~/.lsp.yml |
| 4 | Plugin LSP configs (marketplace / --plugin-dir roots) |
| 3 | User config dirs: ~/.veyyon/profiles/default/agent/lsp.* (active agent dir), ~/.claude/lsp.*, ~/.codex/lsp.*, ~/.gemini/lsp.* |
| 2 | Project config dirs: <project>/.veyyon/lsp.*, <project>/.claude/lsp.*, <project>/.codex/lsp.*, <project>/.gemini/lsp.* |
| 1 (highest) | Project root: <project>/lsp.* and <project>/.lsp.* |
Each location accepts .json, .yaml, and .yml variants, including hidden-file versions (.lsp.json, .lsp.yaml, .lsp.yml). Files are merged in order: higher-priority files override lower-priority fields for the same server. Servers not mentioned in any override file remain at their built-in defaults.
Recommended locations:
- User-wide preferences →
~/.veyyon/profiles/default/agent/lsp.json - Project-specific overrides →
<project>/.veyyon/lsp.json
Note: Auto-detection is skipped only when at least one config file contributes server overrides. A config file that only sets
idleTimeoutMsstill lets Veyyon auto-detect built-in servers. When server overrides exist, Veyyon merges them with defaults and then loads servers that have matchingrootMarkers, an available binary, and are not explicitlydisabled.
File shape
Both JSON and YAML are accepted. The top-level object can use either a servers wrapper key or a flat map directly:
{
"servers": {
"server-name": { ... }
},
"idleTimeoutMs": 300000
}
or (flat, without the servers wrapper):
{
"server-name": { ... },
"idleTimeoutMs": 300000
}
Top-level keys:
servers: map of server name toServerConfig(optional wrapper; flat form is equivalent)idleTimeoutMs: shut down idle language servers after this many milliseconds; disabled by default
ServerConfig fields
| Field | Type | Required | Description |
|---|---|---|---|
command | string | yes | Binary name (resolved via PATH/local bins) or absolute path |
args | string[] | no | Arguments passed to the binary |
fileTypes | string[] | yes | File extensions this server handles, e.g. [".ts", ".tsx"] |
rootMarkers | string[] | yes | Files/dirs that indicate a project root; glob patterns (e.g. *.cabal) are supported |
initOptions | object | no | Sent as initializationOptions during LSP handshake |
settings | object | no | Workspace settings pushed via workspace/didChangeConfiguration |
disabled | boolean | no | Set to true to disable this server entirely |
warmupTimeoutMs | number | no | Startup timeout in ms for this server (overrides the global default) |
isLinter | boolean | no | Mark server as linter/formatter only; excluded from type-intelligence operations (hover, go-to-definition, etc.) |
capabilities | object | no | Opt-in server-specific features; see Capabilities |
resolvedCommand is populated automatically at runtime, do not set it manually.
Capabilities
The capabilities object enables optional server-specific features that Veyyon supports on a per-server basis:
{
"capabilities": {
"flycheck": true,
"ssr": true,
"expandMacro": true,
"runnables": true,
"relatedTests": true
}
}
All fields are boolean and optional. They are currently used by rust-analyzer.
Common recipes
Override a built-in server’s settings
Partial overrides are merged onto the built-in defaults. You only need to specify the fields you want to change.
{
"servers": {
"typescript-language-server": {
"args": ["--stdio", "--log-level", "4"]
}
}
}
servers:
gopls:
settings:
gopls:
gofumpt: false
staticcheck: false
Disable a built-in server
{
"servers": {
"eslint": {
"disabled": true
}
}
}
Register a custom server
New servers require command, fileTypes, and rootMarkers. All other fields are optional.
{
"servers": {
"my-lsp": {
"command": "my-lsp-server",
"args": ["--stdio"],
"fileTypes": [".xyz"],
"rootMarkers": [".xyz-project", ".git"]
}
}
}
Set a global idle timeout
Shut down language servers that have been inactive for more than five minutes:
{
"idleTimeoutMs": 300000
}
Disable a server for one project, keep it globally
Place the override in <project>/.veyyon/lsp.json:
{
"servers": {
"pylsp": {
"disabled": true
}
}
}
The user-level config in ~/.veyyon/profiles/default/agent/lsp.json is unaffected; pylsp is only suppressed in this project.
Built-in server list
The following servers ship in defaults.json and are eligible for auto-detection:
| Server key | Language(s) | Binary |
|---|---|---|
rust-analyzer | Rust | rust-analyzer |
clangd | C, C++, ObjC | clangd |
zls | Zig | zls |
gopls | Go | gopls |
typescript-language-server | TypeScript, JavaScript | typescript-language-server |
denols | TypeScript, JavaScript (Deno) | deno |
biome | TS/JS/JSON (linter) | biome |
eslint | TS/JS/Vue/Svelte (linter) | vscode-eslint-language-server |
vscode-html-language-server | HTML | vscode-html-language-server |
vscode-css-language-server | CSS, SCSS, Less | vscode-css-language-server |
vscode-json-language-server | JSON | vscode-json-language-server |
tailwindcss | HTML, CSS, TS/JS | tailwindcss-language-server |
svelte | Svelte | svelteserver |
vue-language-server | Vue | vue-language-server |
astro | Astro | astro-ls |
pyright | Python | pyright-langserver |
basedpyright | Python | basedpyright-langserver |
pylsp | Python | pylsp |
ruff | Python (linter) | ruff |
jdtls | Java | jdtls |
kotlin-lsp | Kotlin | kotlin-lsp |
metals | Scala | metals |
hls | Haskell | haskell-language-server-wrapper |
ocamllsp | OCaml | ocamllsp |
elixirls | Elixir | elixir-ls |
expert | Elixir | expert |
erlangls | Erlang | erlang_ls |
gleam | Gleam | gleam |
solargraph | Ruby | solargraph |
ruby-lsp | Ruby | ruby-lsp |
rubocop | Ruby (linter) | rubocop |
bashls | Bash, Zsh | bash-language-server |
lua-language-server | Lua | lua-language-server |
intelephense | PHP | intelephense |
phpactor | PHP | phpactor |
omnisharp | C# | omnisharp |
yamlls | YAML | yaml-language-server |
terraformls | Terraform | terraform-ls |
dockerls | Dockerfile | docker-langserver |
helm-ls | Helm | helm_ls |
nixd | Nix | nixd |
nil | Nix | nil |
ols | Odin | ols |
dartls | Dart | dart |
marksman | Markdown | marksman |
texlab | LaTeX | texlab |
graphql | GraphQL | graphql-lsp |
prismals | Prisma | prisma-language-server |
vimls | Vim script | vim-language-server |
emmet-language-server | HTML, CSS, JSX | emmet-language-server |
sourcekit-lsp | Swift | sourcekit-lsp |
swiftlint | Swift (linter) | swiftlint |
tlaplus | TLA+ | tlapm_lsp |
Advisor, WATCHDOG.md, and WATCHDOG.yml
The advisor is an optional second model attached to a session. It reviews the primary agent’s transcript after each turn, inspects the workspace with its own tools, and injects concise advice back into the primary session.
The advisor is not a second executor: it cannot approve actions or change primary session state directly. Its default toolset is read-only (read, search) plus advise, but a WATCHDOG.yml roster entry may broaden tools: to any built-in, including mutating tools such as edit, write, bash, eval, and browser, so grant those tools only when the advisor model and workspace are trusted (see Tools and isolation).
Implementation files
src/advisor/runtime.tssrc/advisor/advise-tool.tssrc/advisor/emission-guard.tssrc/advisor/watchdog.tssrc/advisor/transcript-recorder.tssrc/prompts/advisor/system.mdsrc/prompts/advisor/advise-tool.mdsrc/session/agent-session.tssrc/slash-commands/builtin-registry.tssrc/config/settings-schema.ts
Enabling the advisor
Set advisor.enabled: true, in /settings → Model → Advisor or in the config file.
Which model the advisor runs is the Advisor Model row, directly below the toggle in that same group. It is the only settings surface for the choice; the Roles table does not list the advisor. Leave it unset and the advisor runs the live main model.
The row writes modelRoles.advisor:
modelRoles:
advisor: anthropic/claude-sonnet-4-5:medium
advisor:
enabled: true
Resolution is normal model-role resolution: provider-prefixed ids, canonical ids, and optional
thinking suffixes. A WATCHDOG.yml roster entry’s own model: overrides it for that advisor.
/advisor controls the advisor from inside a session:
| Command | Effect |
|---|---|
/advisor status | Report whether the advisor is running, on what model, and what it has spent. |
/advisor configure | Open the full-screen WATCHDOG.yml roster editor; a save applies to the running session. |
/advisor on, /advisor off | Start or stop the advisor for this session. |
/advisor dump | Copy the advisor’s own transcript to the clipboard. |
A bare /advisor opens a picker listing those subcommands.
/advisor on and /advisor off last for the session. These surfaces set the persistent default:
| Surface | Effect |
|---|---|
advisor.enabled setting | Persisted toggle. Set it in /settings, with veyyon config set advisor.enabled true, or in config.yml. The runtime starts when an advisor model is assigned. |
--advisor CLI flag | Enable the advisor for the launched session. |
WATCHDOG.yml | Define the advisor roster (models, tools, prompts); see below. |
If advisor.enabled is true but no modelRoles.advisor value resolves to an available model, the advisor stays inactive until a model is assigned.
What the advisor sees
At each primary turn end, AdvisorRuntime receives only the new transcript delta since the last advisor update. Deltas are rendered with formatSessionHistoryMarkdown(..., { includeThinking: true, includeToolIntent: true, watchedRoles: true, expandPrimaryContext: true }), so the advisor can review assistant reasoning as well as user-visible text, tool calls, and tool results.
Most hidden custom messages collapse to a one-line summary in the delta. The exception is the primary agent’s injected constraint context, the types in PRIMARY_CONTEXT_CUSTOM_TYPES (plan-mode-context, plan-mode-reference). expandPrimaryContext renders these verbatim inside a <primary-context kind="…"> wrapper (XML-escaped, so plan/objective text cannot break out or read as advisor instructions). Without this the advisor only saw a 120-char truncation of the plan-mode rules, which cut off mid-sentence at NEVER create, edit, or delete files — excep…, hiding the “except the single plan file” carve-out and producing false blockers against the agent writing its own plan file. Because these prompts are re-injected verbatim every primary turn, AdvisorRuntime dedupes them: a byte-identical re-injection collapses to a (unchanged — still in effect) marker, and the full body re-expands whenever the content changes or the advisor re-primes. goal-mode-context is deliberately excluded, its live budget counters change every turn, so it can neither dedupe nor expand cheaply.
Advisor messages already injected into the primary transcript are filtered out before the next delta is rendered. This prevents the advisor from recursively reviewing its own advice.
When the primary transcript is rewritten, the advisor runtime is reset:
- compaction
- session switch/resume
- branch/fork style history replacement
- context-maintenance re-prime when the advisor’s own context cannot fit
Reset clears the advisor’s private in-memory transcript and rewinds its cursor. The next advisor update replays the current bounded primary transcript instead of continuing from stale pre-rewrite context.
When the advisor is enabled mid-session, the cursor seeds to the current primary transcript length. That avoids replaying the whole old conversation on the first enabled turn.
Tools and isolation
The advisor is a full agent with its own Agent instance and a distinct ToolSession whose id is suffixed -advisor. The advisor therefore does not share the primary agent’s file snapshots, seen-lines tracking, conflict state, summary cache, or edit/yield capabilities.
Every advisor has the advise tool for surfacing notes into the primary transcript. Its investigative pool defaults to the read-only subset:
readsearch
A WATCHDOG.yml roster entry may broaden this with tools: [...], selecting any subset of the built-in pool the session actually built (a factory that returned null, e.g. lsp with no matching servers, is absent). Grantable tools include mutating ones: edit, write, bash, eval, browser, debug, ast_edit, task, job, and the memory tools. Tool names outside BUILTIN_TOOL_NAMES are dropped with a warning.
Advisor grants are not routed through the primary agent’s approval wrapper. The advisor pool is built from the built-in tool factories against its own -advisor ToolSession and then filtered by WATCHDOG.yml; it is not the primary toolRegistry wrapped with ExtensionToolWrapper. Granting write- or exec-tier tools therefore lets the advisor invoke those tools directly, subject to the tool’s own runtime guards but not to tools.approvalMode / tools.approval.<tool> prompts. Keep mutating grants narrow and trusted.
The advise tool accepts one note and an optional severity:
| Severity | Delivery | Intended use |
|---|---|---|
omitted / nit | Non-interrupting aside, batched into the primary transcript at the next step boundary. | Cleanup, simplification, low-risk edge cases. |
concern | Interrupting steering message. | Material risk, likely wrong direction, missing constraint, hallucinated API. |
blocker | Interrupting steering message. | Continuing would clearly waste work or produce broken output. |
Interrupting advice is sent through the steering channel and can abort in-flight tools at the next steering boundary. Each note (interrupting or batched) is rendered into the primary transcript as an <advisory> element, severity rides a severity attribute, and a guidance attribute contains the “weigh, don’t blindly obey” framing (the primary agent’s system prompt never mentions advisories, so the tag is its only cue). Note bodies are XML-escaped so advice containing <, >, or & can’t break the wrapper:
<advisory severity="concern" guidance="weigh, don't blindly obey">
note text
</advisory>
When you deliberately interrupt the agent (Esc, or a cancel from collab, ACP, RPC, the SDK, or an extension), the advisor stops auto-resuming it. An interrupting concern/blocker raised while the run is stopped is recorded as a visible advisor card instead of restarting the turn, and a concern already in flight when you interrupt is preserved the same way rather than driving a surprise resume. The advice re-enters context the next time you resume, a new message, the ./c continue shortcut, or a steer/follow-up. A normal yield is unaffected: the advisor can still steer and resume a run the agent ended on its own.
advisor.immuneTurns limits interruption frequency. After the advisor successfully delivers a concern or blocker through the steering channel, later concerns/blockers are routed as non-interrupting asides until the configured number of primary turns has completed. The default is 3. nit notes are unchanged, and advice raised while user-interrupt auto-resume suppression is active is still preserved instead of restarting a stopped run.
Emission guard
AdvisorEmissionGuard (in src/advisor/emission-guard.ts) sits on the enqueueAdvice boundary in AgentSession and enforces, in code, the advisor system prompt’s “at most one advise per update” and “NEVER send the same advice twice” rules. Each call to the advisor’s advise tool runs through the guard before it routes to the YieldQueue / steer channel:
- Normalization. Lowercase, NFKC, collapse every run of non-alphanumeric characters to a single space, trim.
"Stop.","*Stop*", and" stop "all key tostop. - Content-free phrase filter. A small allowlist of normalized phrases the advisor occasionally emits but that carry no concrete reason,
stop,done,complete,no issue continue,lgtm,nothing to add,no further input, and similar, is suppressed silently. Silence is the correct expression of “no concerns”. - Exact-text dedupe. Any normalized note already accepted in this session is dropped. The dedupe history is bounded by a FIFO ring (default 4096 entries).
- Per-update rate limit. At most one note per advisor model
prompt()cycle is accepted; the runtime callshost.beginAdvisorUpdate?.()before each cycle to reset the gate. Suppressed calls never consume the budget: a noise call doesn’t displace a real concern that follows in the same update.
Suppression is invisible to the advisor model: AdviseTool still returns Recorded. for a dropped call. Surfacing “suppressed” back into advisor context risks the model rephrasing the same useless note to bypass the dedupe.
The guard’s full state, dedupe history and per-update gate, clears on every advisor reset (compaction, session switch, /new), so a re-primed reviewer can re-raise issues it already raised against the rewritten transcript.
Bounded catch-up with advisor.syncBacklog
advisor.syncBacklog is not lockstep turn execution. It is a bounded catch-up delay for the primary agent when the advisor falls behind.
Allowed values:
off: never wait for advisor catch-up135
On primary turn end:
- the primary turn delta is queued for the advisor
- the advisor drain loop starts or continues in the background
- if
advisor.syncBacklogis notoff, the primary agent waits only while advisor backlog is at or above the configured threshold - the wait is capped at 30 seconds
- if the advisor catches up below the threshold, the primary continues immediately
- if the cap expires, the primary continues anyway
Practical interpretation:
offfavors maximum primary throughput.1is the closest mode to synchronous review: after each queued advisor delta, the primary waits up to 30 seconds for backlog to return to zero.3and5allow more advisor lag before the primary pauses.
Advisor failures do not permanently stall the primary. A failed advisor prompt is retried; after three consecutive advisor failures, the runtime logs a warning, drops the backlog, and lets the session continue.
WATCHDOG.md
WATCHDOG.md is advisor-only guidance. It is appended to the advisor system prompt; it is not injected into the primary agent’s normal context and does not behave like AGENTS.md, RULES.md, or other context files.
Use it for review priorities: risks the advisor should watch for, project-specific traps, dangerous APIs, architectural boundaries, and quality bars that are useful to a reviewer but too noisy for the main executor.
Example:
# Watchdog notes
Especially watch for:
- Changes that bypass the durable queue in `src/jobs/`.
- UI renderer paths that display unsanitized tool output.
- New worker spawns that do not re-enter the CLI host.
Discovery locations
discoverWatchdogFiles(cwd, agentDir) loads every readable candidate from these locations:
- user level:
<active agent dir>/WATCHDOG.md(~/.veyyon/profiles/default/agent/WATCHDOG.mdby default; relocated byVEYYON_CODING_AGENT_DIR/ profile) - project levels while walking from
cwdupward to the git repository root, or to the home directory when no repo root is found:<dir>/WATCHDOG.md<dir>/.veyyon/WATCHDOG.md
Unlike native context files, watchdog discovery does not stop at the nearest project file. Multiple project watchdog files can load together.
Candidates in hidden owner directories are ignored unless the file is inside an .veyyon directory. This keeps unrelated dot-directory conventions from being picked up accidentally while still allowing .veyyon/WATCHDOG.md.
@ imports
WATCHDOG.md content is expanded with the same @ import helper used by context files:
- relative imports resolve from the importing file’s directory
~/resolves from the user’s home directory- imports inside fenced code blocks and inline code spans stay literal
- cycles are skipped
- missing or unreadable imports leave the original
@pathtext in place
Prompt order
Loaded watchdog blocks are sorted as:
- user-level
WATCHDOG.md - project-level files from farther ancestors down toward
cwd
Each file is appended to the advisor system prompt as:
Especially pay attention to:
<attention>
...expanded watchdog content...
</attention>
Later project files sit closer to the end of the advisor prompt, so narrower directory guidance is more prominent than broad ancestor guidance.
WATCHDOG.yml
WATCHDOG.yml (or WATCHDOG.yaml) is the advisor roster. Where WATCHDOG.md supplies review priorities, WATCHDOG.yml declares the advisors themselves, one entry per name, each with its own model, tool grant, and specialization prompt. You edit this file directly in your editor; the /advisor slash commands, including the configure overlay, were removed. Files that fail to parse or fail schema validation are logged and skipped so one bad project config cannot kill the session.
Example:
instructions: |
Everyone: prefer diffs that keep tests unified.
advisors:
- name: Architecture
model: anthropic/claude-sonnet-4-5:medium
tools: [read, search]
instructions: |
Watch cross-module coupling and public-API growth.
- name: Fixer
model: anthropic/claude-sonnet-4-5:high
tools: [read, search, edit, bash]
instructions: |
You may edit and run tests to prove a fix locally, then advise.
Fields:
instructions(top level): shared prompt prepended to every advisor’s system prompt alongsideWATCHDOG.md. Concatenated across all discoveredWATCHDOG.ymlfiles.advisors[].name: human label; slugified for the session id and the<session>/__advisor.jsonlfilename. Duplicate slugs across files are resolved by the same specificity rule asWATCHDOG.mddiscovery (project leaf > project ancestor > user).advisors[].model: optional model selector with optional:levelthinking suffix (e.g.x-ai/grok-code-fast:high). Omitted → the advisor usesmodelRoles.advisor.advisors[].tools: optional list of built-in tool names to grant. Omitted or empty → the defaultread/searchsubset. Any name inBUILTIN_TOOL_NAMESis accepted, including mutating tools (edit,write,bash,eval,browser,debug,ast_edit,task,job, and the memory tools). Unknown names are dropped with a warning. See Tools and isolation for the safety implications of granting mutating tools.advisors[].instructions: this advisor’s specialization, appended after the shared baseline. Both instruction fields expand@pathimports likeWATCHDOG.md.
Discovery locations
WATCHDOG.yml/WATCHDOG.yaml share the same user + project search path as WATCHDOG.md: the user-level <active agent dir>/WATCHDOG.yml plus every WATCHDOG.yml/.veyyon/WATCHDOG.yml encountered while walking from cwd up to the repository root (or the home directory when no repo root is found). All discovered files are loaded together; a more-specific file (project leaf > project ancestor > user) replaces an earlier entry with the same advisor slug.
Subagents
advisor.subagents controls whether spawned task/eval subagents also get an advisor runtime.
false(default): only the main session can run an advisor.true: eligible subagent sessions build their own advisor with the same settings/model-role resolution, then rerunWATCHDOG.mddiscovery for that subagent session’scwdand agent directory.
Subagent advisors remain isolated from the subagent’s primary tool session in the same way the main advisor is isolated from the main agent.
Cost and context behavior
Advisor usage is separate model usage, tracked on the advisor agent’s own transcript.
The advisor has its own append-only context. Before each advisor prompt, AgentSession estimates incoming tokens and may maintain advisor context:
- try model-level context promotion when enabled and a larger compatible model is available
- if promotion cannot fit enough context, compact the advisor’s own message history
- if compaction has no candidates or still cannot fit, re-prime from the current bounded primary transcript
The advisor’s live context is in-memory and append-only; it is retained while the session runs, and is independently promoted/compacted/re-primed (above). It is not a replacement for the primary persisted transcript.
Transcript persistence and observability
The advisor is a passive reviewer with its own model usage, so, like a task subagent, every finalized advisor turn is appended to a JSONL inside the owning session’s artifacts dir:
- main session:
<session>/__advisor.jsonl - subagent advisor (
advisor.subagents: true):<session>/<SubId>/__advisor.jsonl
The path is derived from the session file (not the artifacts dir, which subagents share with their parent), so each advisor writes a distinct file. The reserved __advisor stem cannot collide with a task subagent’s <id>.jsonl (task id allocation reserves it).
Why a file:
- Usage attribution.
veyyon statsscans each session folder recursively, so advisor assistant turns (with their usage/cost) are attributed to the same project/session like any other subagent. Advisor “session update” prompts are persisted assynthetic, agent-attributed user messages so they never inflate user-message metrics. - Observability. The subagent dashboard discovers
__advisor.jsonlon open and shows it as a read-onlyadvisor-kind transcript under its owning session. Opening it there shows the transcript rather than handing the main view over, because an advisor is not a session you can talk to.
The file follows session switches: on /new, resume/switch, and branch the recorder reopens at the new session’s path on the next advisor turn; before a /drop deletes the old artifacts dir the recorder feed is detached and drained so a queued write cannot recreate the deleted file. The on-disk log is append-only and independent of the in-memory context, re-primes and compaction never truncate it.
The advisor is never a peer. The advisor-kind registry ref is excluded from every agent-facing surface, the irc peer roster and broadcast targets, the subagent peer prompt, and the history:// index/lookup/completions, and cannot be messaged (irc send and collab chat reject it) or revived/killed from the subagent dashboard or collab. It is not addressable as a peer, regardless of what tools it has been granted.
Autoresearch
Autoresearch runs an optimization loop. You give it a benchmark and a metric; it changes code, measures, keeps what improves the metric, and reverts what does not. It stops when you interrupt it or the iteration cap is reached.
Start it with /autoresearch, optionally with a goal:
/autoresearch make the tokenizer faster
The harness
Autoresearch measures through one file, autoresearch.sh, in the repository
root. Write it before the loop starts. It must exit 0 and print at least one
metric line:
#!/usr/bin/env bash
python3 bench.py # prints: METRIC ms=192.78
Two line formats are read back from its output:
| Line | Meaning |
|---|---|
METRIC name=value | A number the loop compares between runs. |
ASI key=value | Free-form metadata attached to the run. |
The primary metric decides whether a change is kept. Secondary metrics are recorded and shown but do not decide anything.
Autoresearch commits autoresearch.sh on a dedicated autoresearch/* branch
before the first iteration, and that commit is the baseline every later run is
measured against. Editing the harness mid-session invalidates the comparison, so
change it only alongside a new segment.
Segments
A segment is one baseline and the runs measured against it. Bumping the segment
starts a fresh baseline inside the same session, which is what you want after
changing the harness or the target. The agent bumps it by passing
new_segment: true.
Scope
scope_paths lists what the loop expects to modify; off_limits lists what it
must not. Neither blocks an edit. Both are recorded: a run that touches an
off-limits path is logged with a scope deviation, and keeping it without a
justification is reported in the next iteration.
The harness itself belongs in off_limits. A loop that is allowed to edit its
own benchmark can improve the number without improving the code.
Going wider
/autoresearch tries one change per iteration. Autoswarm is
the same loop with several candidate arms per iteration, cross-reviewed before
one is kept. Everything on this page — the harness, segments, scope, the
correctness warning below — applies to both.
Correctness is the harness’s job
Autoresearch compares numbers. It does not know whether the code still works, and nothing in the loop discovers that a faster implementation is wrong.
Make autoresearch.sh exit non-zero when the result is wrong, and cover the
inputs the optimization could break. An ASCII-only gate on a string algorithm
accepts an arm that is wrong on every non-ASCII input, because it never tries
one. Include the boundaries the change is likely to move: empty input, the block
sizes of any algorithm you expect to be reached for, non-ASCII text, and the
degenerate cases.
Tools
These attach in autoresearch and autoswarm, and nowhere else.
| Tool | Purpose |
|---|---|
init_experiment | Open or reconfigure the session; set metric, direction, scope, breadth. |
run_experiment | Run the harness and parse its metric lines. Takes arm in autoswarm. |
log_experiment | Record a run as keep, discard, crash, or checks_failed. |
certify_arms | Triage one iteration’s arms and assign cross-review. Attaches in autoswarm only. |
update_notes | Edit the durable session playbook, which is injected each iteration. |
Ending a session
/autoresearch off leaves the mode and keeps the session. /autoresearch clear
resets the worktree to the baseline and closes the session; --keep-tree leaves
your files alone. /autoswarm takes the same two.
State is stored per repository, under the profile directory. The database is
keyed on the primary checkout, so worktrees of one repository share it.
VEYYON_AUTORESEARCH_DB_DIR overrides the location.
Autoswarm
Autoswarm is autoresearch with breadth. An iteration builds several candidate arms instead of one change, rejects the ones that cannot be trusted, has the survivors review each other, and keeps at most one.
/autoswarm opens a setup console:
/autoswarm
Autoswarm setup
Autoresearch with breadth. The model derives the metric from your harness.
› Goal make the tokenizer faster▌ type to edit
Breadth 3 candidate arms per iteration
Attempts 1 retries before an arm is abandoned
Certification on arms cross-review before one is kept
3 arms in a review ring: each arm is reviewed by another, and no pair reviews each other.
↑↓ field ←→ adjust space toggle enter start esc cancel
Up and down move between fields, left and right change the focused value, space
toggles certification, Enter starts the run and Escape leaves without starting
one. Text typed after the command prefills the goal, so /autoswarm make the tokenizer faster opens the console with that goal already in the field.
The console opens on whatever the current branch is already doing, so running it during a session shows that session’s breadth rather than the default, and starting applies the new values from the next iteration.
Everything autoresearch provides is unchanged underneath: the same
autoresearch.sh harness, the same metric lines, the same segments, the same
scope rules, the same database. Read that page first; this one covers only what
breadth adds.
/autoresearch is still there and still serial. Autoswarm does not replace it.
Breadth
Breadth is 1 to 8 and opens at 3, the fewest arms a review ring needs. The
dashboard shows breadth N whenever it is above 1.
Arms share one worktree. They are built one at a time, measured, and reverted, so breadth costs iteration time rather than disk. An arm is a different idea: two arms that produce the same diff are counted once.
Breadth 1 is the serial loop exactly. No arms, no review, no certification cost.
Why arms are reviewed
Breadth searches wider, but that is the smaller half. A loop scored on a number will find ways to move the number that have nothing to do with the work getting faster, and a single agent measuring its own change has no one to catch it.
Four rejections happen mechanically, before a reviewer sees anything:
| Rejection | What it catches |
|---|---|
empty | An arm that changed nothing. |
scope | An arm that edited an off-limits path. |
opaque | A diff that cannot be read: a git binary patch, or a run of 512 or more base64 characters. |
duplicate | An arm whose diff another arm already produced. |
opaque closes a specific hole. A compiled artifact encoded as a base64 string
and decoded at import time reads as an enormous speedup, passes an ASCII-only
correctness gate, and cannot be reviewed by reading it. A diff nobody can read
is rejected rather than measured.
What remains is assigned a reviewer:
| Survivors | Reviewer |
|---|---|
| 0 | none |
| 1 or 2 | the director reviews each arm |
| 3 or more | a ring, where each arm reviews the next and no pair reviews each other |
A ring needs three arms. Two arms reviewing each other is a reciprocal pair, which is the arrangement a ring exists to avoid. When breadth is 3 or more but fewer arms survive, review falls back to the director and the fallback is reported rather than applied silently.
A reviewer flags an arm when the metric moved for a reason other than the work getting faster: a hardcoded answer, a cache keyed on the benchmark’s own inputs, a narrowed input space, a weakened check, or work relocated out of the timed region. A flagged arm cannot win, however good its number is. When every improvement is flagged the iteration is a null round, which is a result and is logged as one.
Certification can be turned off for a session, which leaves the director as the only reviewer. It stays on by default.
Relocated cost
A change that moves work out of the timed region lowers the metric without making anything faster. Compiling at import time instead of at call time is the common shape.
Have the harness report what a fresh checkout pays, as a second metric:
python3 bench.py # prints: METRIC ms=0.10
python3 cold_start.py # prints: METRIC cold_ms=512.25
Growth above 25ms against the baseline’s own cold metric is stated to the
reviewer as a measured fact. Without a cold_ms line nothing is checked, and a
0.10ms result that hides half a second of compilation is indistinguishable from
a real one.
What certification does not do
It does not check that the code is still correct. That is the harness’s job, and a reviewer reads a diff rather than running the tests you did not write. The correctness section of the autoresearch page applies with more force here, because breadth produces more candidates and the wrong ones are the fast ones.
It also does not make a reviewer right. An arm is flagged by an agent reading a diff against a hypothesis. The mechanical rejections above hold whatever the reviewer concludes; the judgement on top of them does not.
Session state
Breadth, attempts and certification belong to the session rather than the
installation, so the setup console sets them per investigation and /settings
does not carry them. A run records which arm produced it and which reviewer
certified it.
Eval Tool Python Backend
The Python execution stack in packages/coding-agent: tool behavior, runner lifecycle,
environment handling, execution semantics, output rendering, supported magics, and
operational failure modes.
Scope and Key Files
- Tool surface:
src/tools/eval.ts - Session/per-call kernel orchestration:
src/eval/py/executor.ts - Subprocess kernel client:
src/eval/py/kernel.ts - Python wrapper / NDJSON server:
src/eval/py/runner.py - Prelude helpers loaded into every kernel:
src/eval/py/prelude.py - Host-side subagent helper bridge:
src/eval/agent-bridge.ts - MIME bundle renderer (text + structured outputs):
src/eval/py/display.ts - Interactive-mode renderer for user-triggered Python runs:
src/modes/components/eval-execution.ts - Runtime/env filtering and Python resolution:
src/eval/py/runtime.ts
What eval’s Python backend is
The eval tool executes one Python cell per call inside a retained python subprocess that speaks NDJSON over stdin/stdout. No Jupyter gateway and no extra pip dependencies are required, a vanilla Python 3.8+ interpreter is enough. Rich display() output (PIL, pandas, plotly, matplotlib figures) keeps working because the wrapper implements MIME-bundle dispatch. State persists across calls in the retained kernel, so define helpers and datasets in one call and reuse them in the next.
Tool params (one cell per call):
{
language: "py" | "js" | "rb" | "jl"; // enum narrowed per session to the enabled backends
code: string;
title?: string;
timeout?: number; // seconds, clamped to 1..3600, default 30. Inactivity budget, see "Cell timeout".
reset?: boolean; // reset this language's kernel before execution
}
The tool is concurrency = "exclusive" for a session, so calls do not overlap.
Kernel lifecycle
Each Python kernel is a single subprocess: <resolved-python> -u <runner.py>. The runner is bundled with the host binary (Bun text import), written to a veyyon-python-runner cache under the OS temp directory once per script hash, and reused by subsequent spawns.
Kernel startup sequence:
- Availability check (
checkPythonKernelAvailability): verifies that a Python interpreter resolves and runs. - Spawn
python -u runner.pywith filtered env andcwd. - Send an init request that runs
os.chdir(cwd), injects env entries, and addscwdtosys.path. - Execute
PYTHON_PRELUDE(idempotent: only initializes once per process).
Kernel shutdown:
- Send
{"type": "exit"}over stdin. - Wait for process exit with
SHUTDOWN_GRACE_MSbudget. - Escalate to
SIGTERMand finallySIGKILLif the process does not exit in time.
Wire protocol (NDJSON, host ↔ runner)
One JSON object per line, UTF-8, \n terminated.
Host → runner:
{"id": "<reqId>", "code": "<source>", "silent": false, "storeHistory": true, "cwd": "<optional>", "env": {"KEY": "VAL"}}
{"type": "exit"}
Runner → host:
{"type": "started", "id": "<reqId>"}
{"type": "stdout", "id": "<reqId>", "data": "..."}
{"type": "stderr", "id": "<reqId>", "data": "..."}
{"type": "display", "id": "<reqId>", "bundle": {<mime>: <value>}}
{"type": "result", "id": "<reqId>", "bundle": {<mime>: <value>}}
{"type": "error", "id": "<reqId>", "ename": "...", "evalue": "...", "traceback": ["..."]}
{"type": "done", "id": "<reqId>", "status": "ok"|"error", "executionCount": N, "cancelled": false}
Status events the prelude emits (e.g. _emit_status("find", count=…)) ship inside display bundles under application/x-veyyon-status so the existing TUI status renderer keeps working.
Magics
The runner’s source transformer rewrites IPython-style magics to plain Python calls before parsing. Supported set:
| Magic | Effect |
|---|---|
%pip <args> | python -m pip <args> with live streaming output. Newly installed packages are evicted from sys.modules so the next import picks up the fresh install. |
%cd <path> | os.chdir(path) (with ~ expansion); emits status event. |
%pwd | Returns os.getcwd(). |
%ls [path] | Returns sorted(os.listdir(path)). |
%env [KEY[=VAL]] | List, read, or set env vars (matches prelude env() semantics). |
%set_env KEY VALUE | Set os.environ[KEY]. |
%time <expr> / %timeit <expr> | Time the expression; emits status event with elapsed ms. |
%who / %whos | List user-namespace names. |
%reset | Clear user globals and re-inject prelude. |
%load <path> | Read a file into a fresh cell and execute. |
%run <path> | runpy.run_path and merge globals back. |
%%bash | Run the cell body via /bin/bash. |
%%capture [name] | Run body with stdout/stderr captured into name. |
%%timeit | Time the cell body. |
%%writefile <path> | Write body to file. |
!cmd / var = !cmd | Run command via subprocess shell; returns an SList-style result with .n / .s helpers. |
var = %name args | Assignment forms work for line magics and !cmd. |
Unknown magic names raise NameError: UsageError: ... inside the cell.
Session persistence semantics
python.kernelMode controls retained kernel reuse:
session(default)- Reuses kernel sessions keyed by namespaced eval session id plus normalized cwd and interpreter.
- Multiple owners can share the same retained kernel for that key.
- Calls through the tool are exclusive, so tool invocations do not overlap.
- A dead retained subprocess is replaced before execution.
- If the subprocess dies during execution, it is replaced and the cell is retried once.
per-call- Spawns a fresh subprocess for each request.
- Shuts the subprocess down after the request.
- No cross-call state persistence.
Multi-cell behavior in a single tool call
A call carries exactly one cell. To build up state, make successive calls: the retained kernel keeps every defined name between them. If a cell fails, earlier state remains in memory and the tool returns a targeted error.
reset=true resets that language’s kernel before the cell executes.
Environment filtering and runtime resolution
Environment is filtered before launching the runner:
- Allowlist includes core vars like
PATH,HOME, locale vars,VIRTUAL_ENV,PYTHONPATH, etc. - Allow-prefixes:
LC_,XDG_,VEYYON_ - Denylist strips common API keys (OpenAI/Anthropic/Gemini/etc.)
Runtime selection order (skipped entirely when the python.interpreter setting specifies an explicit executable):
- Active/located venv (
VIRTUAL_ENV, thenCONDA_PREFIX, then<cwd>/.venv,<cwd>/venv) - Managed venv at
~/.veyyon/python-env pythonorpython3on PATH
When a venv is selected, its bin/Scripts path is prepended to PATH.
The runner additionally receives PYTHONUNBUFFERED=1 and PYTHONIOENCODING=utf-8 so streamed output reaches the host promptly.
Tool availability and mode selection
eval.py / eval.js (both default true) plus optional boolean env flags VEYYON_PY / VEYYON_JS control eval backend exposure. Ruby and Julia backends (language: "rb" / "jl") also exist behind eval.rb / eval.jl (both default false) and the VEYYON_RB / VEYYON_JL flags; the language enum is narrowed per session to the enabled backends:
- Python backend only (
eval.py=true,eval.js=false, orVEYYON_PY=1 VEYYON_JS=0) - JavaScript backend only (
eval.py=false,eval.js=true, orVEYYON_PY=0 VEYYON_JS=1) - both backends (
eval.py=true,eval.js=true, orVEYYON_PY=1 VEYYON_JS=1)
VEYYON_PY and VEYYON_JS use normal boolean flag parsing. Each flag, when set, overrides only its own setting; an unset flag falls back to its setting (eval.py / eval.js, both default true).
eval.pyWorkspace defaults to false. When enabled, the model-facing eval description tells the agent to retain large tool.* results in Python variables, transform them in the kernel, reuse helper functions, and display only compact conclusions. It adds guidance only; it does not add Python APIs or change cell execution.
If Python preflight fails and eval.js is enabled, eval remains available for js cells; py cells fail with a Python-backend availability error.
Python prelude helpers include agent(prompt, *, agent="deep", model=None, label=None, schema=None, handle=False, isolated=None, apply=None, merge=None). It synchronously calls the host bridge, runs one subagent through the task executor, and returns the final text. When schema is supplied, the helper parses the subagent’s JSON output and returns the object. When handle=True, it instead returns a DAG node dict ({"text", "output", "handle", "id", "agent"}) whose handle is the spawned agent’s recoverable agent://<id> URI (the parsed object lands under "data" when schema is also set), so a downstream pipeline/parallel stage can reference the transcript by handle instead of re-inlining it.
Persisted helper state
JavaScript and Python cells expose kv for JSON values that must survive a kernel reset or session continuation. The store is scoped to the session under its artifacts directory and is shared across both runtimes:
kv.set("cursor_handle", {"id": "callback-17"})
saved = kv.get("cursor_handle")
keys = kv.list()
kv.delete("cursor_handle")
kv.get(name, default=None) accepts a Python default value. JavaScript returns undefined for a missing key. Keys contain 1–256 characters without /, \, or NUL. One encoded value is limited to 256 KiB and the store is limited to 4 MiB. Concurrent JavaScript and Python writes preserve updates to different keys.
defs() returns up to 200 sorted names defined by user cells with a short value shape. Prelude and runtime names are omitted. Use it to check retained kernel state before redefining a helper.
Execution flow and cancellation/timeout
Cell timeout
Each eval cell timeout is in seconds, defaults to 30, and is clamped to 1..3600. It is a wall-clock budget on the cell’s own work that the watchdog (IdleTimeout, src/eval/idle-timeout.ts) enforces, but it is suspended while a host-side agent()/parallel()/completion() bridge call is in flight: those calls emit synthetic pause/resume timeout-control status events (withBridgeTimeoutPause, src/eval/bridge-timeout.ts) that pause the watchdog entirely and start a fresh timeout window when control returns to the runtime, so a long fanout or a slow completion runs to completion instead of being killed mid-stream. Pause is reference-counted because parallel() can have multiple bridge calls in flight at once.
The pause/resume events are the sole mechanism that suspends the budget. Everything else the cell does, compute, stdout/stderr, log()/phase(), and ordinary (non-agent) tool calls, counts against timeout, so a cell that is not delegating to an agent/completion is bounded by a plain wall-clock timeout. The tool combines the caller abort signal, the session abort signal, and the watchdog’s signal with AbortSignal.any(...); no wall-clock deadline is passed to the backend, so neither runtime arms a competing fixed timer.
Kernel execution cancellation
On abort/timeout:
- The host sends
kill("SIGINT")to the runner subprocess. - The runner’s exec-time signal handler raises
KeyboardInterruptinside the user code. - Result includes
cancelled=true; a kernel timeout is annotated aseval cell timed out after <n>s; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted. - Between requests the runner installs
SIG_IGNfor SIGINT so a stray cancel does not tear down the kernel.
If the runner does not emit done within 5s of the interrupt (INTERRUPT_ESCALATION_MS, e.g. stuck in C code holding the GIL), the host shuts the subprocess down (escalating exit → SIGTERM → SIGKILL), the cell is annotated as kernel-killed, and the kernel is recreated on the next call.
stdin behavior
Interactive stdin is not supported. The runner does not forward input() prompts; user code that calls input() blocks until cancellation.
Output capture and rendering
Captured output classes
From runner frames:
stdout/stderr→ plain text chunksdisplay/result→ rich display handling (MIME bundle)error→ traceback textapplication/x-veyyon-statusMIME insidedisplay→ structured status events
Display MIME precedence:
text/markdowntext/plaintext/html(converted to basic markdown)
Additionally captured as structured outputs:
application/json→ JSON tree dataimage/png/image/jpeg→ image payloadsapplication/x-veyyon-status→ status events
Matplotlib
The runner sets MPLBACKEND=Agg as an environ default so figures render off-screen. After every cell, pyplot.get_fignums() is iterated; each figure is saved to PNG, emitted as an image/png display, and closed.
Storage and truncation
Output is streamed through OutputSink and may be persisted to artifact storage. Tool results can include truncation metadata and artifact://<id> for full output recovery.
Renderer behavior
- Tool renderer (
eval-render.ts, re-exported fromeval.ts):- shows code-cell blocks with per-cell status
- collapsed preview defaults to 10 lines
- supports expanded mode for all output retained in the tool result
- Interactive renderer (
eval-execution.ts):- used for user-triggered Python execution in TUI
- collapsed preview defaults to 20 lines (
EXECUTION_PREVIEW_LINES) - clamps a very long individual line to 4000 terminal columns, not characters (
EXECUTION_MAX_DISPLAY_COLUMNS). Columns are what the terminal has to fit, so ANSI colour codes are not counted and a wide character counts as two. The clamped line ends with… [N visible columns omitted]. - keeps at most 100 output lines while a cell is streaming (
EXECUTION_STREAMING_LINE_CAP, five screenfuls). When more arrive the oldest are dropped and the footer states it:… N earlier lines dropped while streaming. That note is separate from the… N more lines (ctrl+o to expand)hint, because expanding reveals hidden lines and cannot bring back dropped ones. Once the cell finishes, the full output replaces what streaming kept, so the note disappears. - shows cancellation/error/truncation notices
The bash execution block (bash-execution.ts) shares all three of those limits, along with the clamp itself, through modes/components/execution-shared.ts.
Operational troubleshooting
- Python backend not available: Check
eval.py,VEYYON_PY, and thatpython/python3is on PATH. If preflight fails andeval.jsis enabled, use ajscell. - No Python on PATH: Install a system Python 3.8+ or place a venv at
~/.veyyon/python-env.veyyon setup python --checkreports the resolved interpreter. - Execution hangs then times out: Increase tool
timeout(max 3600s) if workload is legitimate. For stuck native code, cancellation triggersSIGINTfirst then escalates; the session restarts on the next request. - stdin/input prompts in Python code:
input()is not supported; pass data programmatically. - Working directory errors: Tool validates
cwdexists and is a directory before execution.
Relevant environment variables
Each variable is read directly from the process environment under its VEYYON_ name; there is no legacy alias resolution.
VEYYON_PY/VEYYON_JS: eval backend exposure overridesVEYYON_PYTHON_SKIP_CHECK=1: bypass Python preflight/warm checksVEYYON_PYTHON_INTEGRATION=1: enable gated integration tests that spawn a real PythonVEYYON_PYTHON_IPC_TRACE=1: log NDJSON frames exchanged with the runner subprocess
MCP
You point Veyyon at an external program (a database bridge, a browser driver, a hosted API) and its
tools show up in the session, ready for the model to call. The Model Context Protocol (MCP) is the
standard that makes this work. Veyyon speaks it as a client: it connects out to MCP servers and
consumes their tools and data. It is not itself an MCP server binary. To embed Veyyon in an editor,
use ACP (veyyon acp); to drive it from your own process, use the SDK.
What it looks like
You register a server in an mcp.json file, and its tools become callable. A minimal local server:
{
"mcpServers": {
"sqlite": {
"type": "stdio",
"command": "node",
"args": ["/path/to/sqlite-mcp/index.js"],
"env": { "DB_PATH": "/var/data/app.db" }
}
}
}
Each tool an MCP server exposes appears namespaced as mcp__<server>_<tool> (a single underscore
between server and tool, for example mcp__sqlite_query), so a server never
shadows a built-in tool or another server. If you turn on discovery.importForeignConfig (off by
default), Veyyon also discovers MCP entries from the user-level configs that Claude, Cursor, Codex,
OpenCode, and related tools already wrote, so a server you configured elsewhere often works
without re-registering it. Discovery never reads a working tree: a repository’s own mcp.json is
not a server source.
For the full setup path, choosing a transport (stdio, http, sse), passing environment
variables, authenticating (bearer token or OAuth), approving tools, the /mcp commands, and fixing
common connection errors, see MCP server setup.
Not to be confused with
- ACP (
veyyon acp): the Agent Client Protocol for driving Veyyon from an editor. That makes Veyyon the agent an editor talks to; MCP makes Veyyon the client that talks to tool servers. - SDK: embedding the agent in your own host process.
Related
- MCP server setup: the configuration and troubleshooting guide
- MCP internals: how the client is built
docs/handbook/src/reference/mcp-config.md: the engineering reference
MCP server setup
Veyyon can connect to third-party Model Context Protocol (MCP) servers so external tools and data sources become available to the agent. Register a server, choose a transport, authenticate, and fix the most common connection problems.
For an overview of what MCP does in Veyyon, see MCP. Engineering reference:
docs/handbook/src/reference/mcp-config.md.
Where servers are configured
MCP servers are configured as JSON in mcp.json, not in config.yml:
| Scope | Path |
|---|---|
| User | ~/.veyyon/profiles/default/agent/mcp.json (profile: ~/.veyyon/profiles/<name>/agent/mcp.json) |
There is no project scope: a repository’s own mcp.json, .mcp.json, or .veyyon/mcp.json is not
read, because a checked-in file must not name a server the agent connects to. No /mcp subcommand
takes a scope, and writing project or user as an argument is rejected with that reason rather than
accepted, in a terminal and in a client alike.
Veyyon also discovers MCP entries from other tools’ user-level configs (Claude, Cursor, Codex,
Gemini, OpenCode, Windsurf). The easiest way to add a server is /mcp add in the TUI, which writes
to mcp.json for you.
File shape
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"sqlite": {
"type": "stdio",
"command": "node",
"args": ["/path/to/sqlite-mcp-server/index.js"]
}
},
"disabledServers": []
}
Top-level keys: mcpServers (map of name to config) and disabledServers (names to turn off). Server
names match ^[a-zA-Z0-9_.-]{1,100}$. Shared per-server fields: enabled, timeout (milliseconds;
0 disables the client-side timeout), auth, and oauth. Stdio servers also take command,
args, env, and cwd; remote servers take url plus type (or a type-less inferred URL)
and headers.
Choose a transport
type | Use when | Fields |
|---|---|---|
stdio (default) | Local executable, script, or binary. | command (required), args, env, cwd |
http | Remote streamable-HTTP service. | url (required), headers |
sse | Legacy SSE service (prefer http). | url (required), headers |
type is optional for stdio because it is inferred from command.
A minimal streamable HTTP server:
{
"mcpServers": {
"analytics": {
"type": "http",
"url": "https://analytics.example.com/mcp"
}
}
}
Pass environment variables
Local stdio servers often need environment variables:
{
"mcpServers": {
"sqlite": {
"type": "stdio",
"command": "node",
"args": ["/path/to/sqlite-mcp-server/index.js"],
"env": { "DB_PATH": "/var/data/app.db", "SQLITE_LOG_LEVEL": "warn" }
}
}
}
A stdio server does not inherit the shell environment. It receives a baseline of variables a
program needs in order to run — PATH, HOME, temp and locale settings, certificate and proxy
settings, and the directories version managers use to resolve a command — plus whatever env
sets. Every other ambient variable, including provider keys and CI tokens, is withheld. On
Windows the baseline also carries PATHEXT, SystemRoot, ComSpec and the ProgramFiles
variants, and names match without regard to case.
To forward an ambient variable, name it:
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"envPassthrough": ["GITHUB_TOKEN"]
}
}
}
inheritEnv: true hands one server the whole environment, including every credential in it. Use
it when a server needs a variable you cannot name in advance. It is set per server, and each
spawn logs a warning stating the command.
{
"mcpServers": {
"legacy": { "command": "/opt/legacy/mcp", "inheritEnv": true }
}
}
For HTTP servers, pass credentials or account ids as headers:
{
"mcpServers": {
"analytics": {
"type": "http",
"url": "https://analytics.example.com/mcp",
"headers": { "Authorization": "Bearer ${ANALYTICS_MCP_TOKEN}", "X-Account-Id": "acct_123" }
}
}
}
Authenticate
Bearer token via header
Keep the token in the environment and reference it from a header rather than committing the raw value:
$ export ANALYTICS_MCP_TOKEN="your-token-value"
OAuth
Some HTTP servers require an interactive OAuth flow. Add an oauth block, then authenticate from the
TUI with /mcp reauth <name>:
{
"mcpServers": {
"crm": {
"type": "http",
"url": "https://crm.example.com/mcp",
"oauth": { "clientId": "veyyon-crm-client" }
}
}
}
Approve tools
Every tool an MCP server exposes appears namespaced as mcp__<server>__<tool> and is governed by the
global tools.approvalMode plus per-tool tools.approval. To require a prompt for a specific server’s
tools, set a per-tool policy:
# ~/.veyyon/profiles/default/agent/config.yml
tools:
approval:
mcp__sqlite__query: prompt
To turn a server off entirely, add its name to disabledServers in mcp.json.
In the TUI
| Command | Purpose |
|---|---|
/mcp | List servers, connection/auth status, and exposed tools |
/mcp add | Add a server (writes mcp.json) |
/mcp list | List configured servers |
/mcp remove <name> | Remove a server |
/mcp test <name> | Test connectivity |
/mcp reauth <name> | Refresh OAuth |
Run /mcp list to see exactly which tools, resources, and templates Veyyon registered.
Resolve common errors
Server not found
For a stdio server, the command is usually not on PATH or the path is wrong. Check it directly:
$ node /path/to/sqlite-mcp-server/index.js
Fix the path, or add the directory to env.PATH. For an http server, check the URL with curl:
$ curl -i https://analytics.example.com/mcp
Timeout
If /mcp shows the server but tool calls time out, raise the per-server timeout (milliseconds), or
set VEYYON_MCP_TIMEOUT_MS for the whole process:
{ "mcpServers": { "analytics": { "type": "http", "url": "…", "timeout": 60000 } } }
Authentication failure
For header tokens, confirm the environment variable is set in the same shell that starts Veyyon. For
OAuth, run /mcp reauth <name> again. If a 401/403 persists, the token may have expired or the server
may require additional headers.
Model cannot see the tools
If a server is connected but the model never uses its tools, check that the server is not in
disabledServers, that enabled is not false, and that no tools.approval entry denies the
namespaced tool. Run /mcp resources to see what was registered.
Where to go next
- MCP for the feature overview (MCP client; ACP is separate).
- Configuration for config file layout and precedence.
- Tools, skills, and extension data for other ways to extend Veyyon.
Connectors and Apps
Veyyon does not ship provider-hosted connectors, account-gated app integrations wired through a provider’s own connector store. Extend it with the integrations below instead.
What ships instead
Extend Veyyon with tools that are implemented and documented today:
| Integration | Purpose |
|---|---|
| MCP | Attach MCP servers; tools appear as mcp__… with approval tiers |
| Plugins | Install extensions; veyyon plugin install … |
| Hooks | Event-driven automation in the agent loop |
| Skills | Bundled instructions and tool patterns |
| OAuth providers | /login and /setup for supported APIs; /providers manages the accounts you have |
Tool policy uses tools.approvalMode and tools.approval.<tool>, same machinery for bash, MCP, and custom tools (docs/handbook/src/reference/approval-mode.md).
Provider-hosted connector stores and apps connector tables are not part of the current product surface.
Use MCP, plugins, hooks, and skills for integrations.
See also
Session branching
Veyyon sessions are trees, not linear chat logs. Each entry has id and parentId; the active position is the current leaf. Branching appends from the chosen parent without deleting sibling branches.
Engine reference: docs/handbook/src/reference/tree-command.md, docs/internal/session-tree-architecture.md.
Navigate in place: /tree
/tree opens the session tree selector (TreeSelectorComponent). It moves the leaf within the same session file, no new session id.
Also opens via:
- Keybinding
app.session.tree - Double-Escape when
doubleEscapeAction = "tree"(default) /branchwhendoubleEscapeAction = "tree"(routes to tree instead of user-message branch picker)
Filters
treeFilterMode setting (cycle with Ctrl+O / Shift+Ctrl+O, or Alt+D/T/U/L/A shortcuts):
| Mode | Shows |
|---|---|
default | Conversation nodes; hides label/custom/model_change/thinking bookkeeping and tool-call-only assistant messages |
no-tools | default plus hides tool-result messages |
user-only | User messages only |
labeled-only | Entries with labels |
all | Every entry type except tool-call-only assistant messages (hidden in all modes unless error/aborted or the current leaf) |
Search is fuzzy, case-insensitive, AND across tokens.
Selection behavior
- User / custom_message: leaf moves to parent; message text prefills composer for edit/resubmit.
- Other entry types: leaf becomes selected node; empty composer.
- Current leaf: no-op.
Labels: Shift+L set/clear; stored as append-only label entries.
Optional branch summary when branchSummary.enabled is true: after picking a node, choose summarize abandoned path before continuing.
New session file: /branch and /fork
| Command | Behavior |
|---|---|
/branch | Pick a user message; copy history up to that boundary into a new session file (or reset root); prefills composer. When doubleEscapeAction = "tree", opens /tree instead. |
/fork | Duplicate the entire current session (every entry, including sibling branches) into a new persisted file (handleForkCommand). No entry picker; for a slice from a chosen point, use /branch. |
| CLI | veyyon --fork <session-id> at startup |
/fork and /branch change session files. /tree does not.
There is no /clone slash command in the shipped registry.
Ephemeral side questions: /btw
/btw <question> (not /side) runs an ephemeral side thread with inherited context, then returns. See implementation handleBtwCommand in interactive-mode.ts.
/tan runs tangential background agent work, separate from /btw.
Configuration
doubleEscapeAction: tree # tree | branch | none
treeFilterMode: default # default | no-tools | user-only | labeled-only | all
branchSummary:
enabled: false
reserveTokens: 16384
Command availability
Branching commands require a started session (at least one turn). Some commands are disabled while tasks run; /btw may remain available for steering, see TUI status when blocked.
See also
Memory
By default, each session starts fresh: Veyyon holds no record of your last one. Turn memory on and it
carries durable project context forward, so a fact it learned yesterday is available today. Memory is
off by default. To use it, you pick a backend, which is the store that holds what Veyyon remembers, in
config.yml or /settings.
Backends
| Backend | Storage | Notes |
|---|---|---|
off | n/a | No memory injection or retention |
local | Markdown under the agent memories dir (MEMORY.md, memory_summary.md, skills/) | Summaries from past session files |
mnemopi | SQLite via @veyyon/mnemopi | Vector + FTS, auto-retain, compaction hooks |
hindsight | Hindsight server (when configured) | Remote bank; retain/recall/reflect tools |
Enable in config:
memory:
backend: mnemopi # or local, hindsight, off
Mnemopi (recommended for long-running work)
With memory.backend: mnemopi, Veyyon:
- Opens scoped SQLite banks (
global,per-project, orper-project-tagged). - Recalls relevant memories into a
<memories>block on the first turn, delivered as a message next to your prompt rather than by rewriting the cached system prompt. - Retains completed turns on a configurable interval (
mnemopi.retainEveryNTurns, default 4). - Supplies pre-compaction context from the memory backend when compaction runs.
Key settings: mnemopi.scoping, mnemopi.recallLimit, mnemopi.autoRecall, mnemopi.autoRetain,
mnemopi.polyphonicRecall, mnemopi.noEmbeddings. See
docs/internal/mnemosyne-memory-backend.md.
Dedicated tools when enabled: recall, retain, reflect, memory_edit.
The /memory slash command exposes view, stats, diagnose, clear, and enqueue.
Local summary pipeline
With memory.backend: local, a background pipeline at startup extracts durable signal from past
session JSONL files, then consolidates into MEMORY.md, memory_summary.md, and optional
skills/. The agent reads artifacts via memory:// URLs on the read tool.
Engineering detail: docs/handbook/src/architecture/memory.md.
Compaction (primary knobs)
Context compaction is separate from durable memory. Settings → Compaction (or config.yml) exposes
these primary fields:
| Setting | Key | Values |
|---|---|---|
| Threshold | compaction.threshold | auto, a percent of the window (85%), or a token amount (170000) |
| Type | compaction.strategy | summary, the sole strategy |
| Model | compaction.model | model id; unset uses the interactive model |
summary rewrites old history into an in-place LLM summary. Run it on demand
with /compact. Use /handoff only when you explicitly want a new session. See
Compaction and project memory.
What the model sees
Recalled or summarized memory is background context, not instructions. Current user messages, tool output, and repo state win on conflict. The agent should cite memory paths when memory changes a plan and pair citations with fresh repo evidence.
It arrives in two places. The guidance that stays the same all session is part of the system prompt. Anything that changes while you work, the memories recalled for your current question and the mental models when they reload, arrives as a message next to your prompt instead.
That split is about cost, not ordering. The provider caches the system prompt as the prefix of every request; changing it mid-session throws that cache away and the next request re-reads the whole conversation at the uncached rate. A recalled memory in the prompt made every recall cost a full re-read. The model reads the same text either way.
A block is sent once. A reload that finds the same memories sends nothing, so your context does not
grow by a copy of your memories every turn. /memory view shows both halves, so what you read there
is what the model gets.
Configuration
Use /memory or /settings (Memory group), or set keys under memory.*, mnemopi.*,
hindsight.*, or memories.* depending on the active backend.
The active backend, its settings, and its stored data (mnemopi SQLite path, local Markdown artifacts, hindsight bank id) are scoped to the active profile (VEYYON_PROFILE). Profiles do not share memory stores.
Profiles
A profile is a directory under ~/.veyyon/profiles/<name>/ holding that identity’s settings, sessions, MCP, skills, hooks, logs, plugins, and caches. One binary; multiple profile trees.
Roles and profile layout: Roles and profiles.
Layout
Every profile, including default, lives under the same tree:
~/.veyyon/
config.yml # GLOBAL settings (defaultProfile, ...), not a profile's settings
install-id # per-install UUID, shared by all profiles
profiles/
default/ # the default profile, a real profile like any other
agent/ # settings, sessions, skills, MCP, keybindings, ...
logs/ plugins/ cache/ wt/ ...
work/
agent/
...
See File locations for the full per-profile tree and the one-time migration from the legacy bare-root layout.
Which profile launches
Resolution order for every veyyon / vey invocation:
--profile <name>.VEYYON_PROFILE. An explicitly emptyVEYYON_PROFILE=forcesdefault, bypassing step 3.defaultProfilein the global~/.veyyon/config.yml: set or show it withveyyon profile default [name], or edit it on the Global tab of/settings.default.
What a profile owns (shipped)
When a profile <name> is active, native Veyyon paths resolve under:
~/.veyyon/profiles/<name>/agent/
That resolution is uniform across settings, sessions, blobs, slash commands, sticky rules, prompts, hooks, tools, extensions, skills, MCP, keybindings, theme, the profile AGENTS.md, RULES.md, and PROMPT_SECTIONS/. Operational state (logs, plugins, caches, worktrees) resolves under the profile root ~/.veyyon/profiles/<name>/ the same way. A profile never reads another profile’s tree at runtime.
Provider credentials are the one exception: by default they live in a machine-wide store (~/.veyyon/shared-auth/agent.db) that every profile reads, so you sign in once. Set profileSharing: false in the global ~/.veyyon/config.yml (or toggle it on the Global tab of /settings) to give each profile its own private credential store instead. See Signing in › Credentials are shared across profiles.
Keybindings: each profile owns agent/keybindings.*. New profiles seeded with veyyon profile new --from default copy the default profile’s keybindings once. On first launch of an older named profile that has no keybindings file, Veyyon performs the same one-time seed and logs it. There is no live merge from the default profile after that.
Project-level dirs (<cwd>/.veyyon, .claude, etc.) are not profile-scoped; they follow the working directory.
Other tools’ config (skills and CLAUDE.md/AGENTS.md written for Claude, Codex, and similar) is off by default and controlled per profile by discovery.importForeignConfig, so each profile decides on its own whether to ambiently read foreign files or run native-only. Another tool’s own global dir (~/.claude/skills, …) cannot be relocated into a profile, see Skills › Profiles isolate skills.
Activating a profile
- CLI:
veyyon --profile <name>(no short form;-pis--print). - Env:
VEYYON_PROFILE=<name>. - TUI:
/profile <name>ends the current conversation and relaunches Veyyon on that profile (a fresh session: profiles are chosen at process start, so there is no hot-swap). Bare/profile(or/profiles) opens the profile picker described below. - Shell alias:
veyyon --profile work --alias myworkinstalls a managed block in your shell rc (seecli/profile-alias.ts).
TUI profile commands
/profiles and /profile are the same command. Run it with no arguments to open the profile picker, an interactive dialog that lists every profile (the active one marked) plus a Create new profile row. Select a profile to open its action menu: Switch to it, Rename it, or Delete it (switch and delete are hidden for the active profile, and the default profile is never offered for deletion). The picker is the fastest way to manage profiles without remembering the verb syntax.
You can also type any verb directly:
| Command | Effect |
|---|---|
/profiles, /profile | Open the profile picker. |
/profile list | Print the profile list as text (active marked with *). |
/profile <name> | Switch to <name> (relaunches as a fresh session). |
/profile switch <name> | Same as /profile <name>. |
/profile new <name>, /profile create <name> | Create <name>, then open the copy-items picker. |
/profile rename <old> to <new> | Set the display name of <old> to <new>. |
/profile rename to <new> | Rename the active profile. |
/profile rm <name>, /profile delete <name> | Delete <name> after a confirmation. Rejects the active and default profiles. |
Choosing Create new profile or Rename in the picker prefills the composer with the matching command (/profile new or /profile <name> rename to ) so you finish by typing the name and pressing Enter. Name entry always flows through the same typed command, so there is one place that creates and renames.
Profile names and renaming
A profile’s directory name (~/.veyyon/profiles/<name>) is its stable identity and never changes. Each profile can additionally carry a display name, the profile.displayName setting, stored in that profile’s own config.yml:
- Settings:
/settings› Interaction › Profile › Profile Name. - TUI:
/profile rename to <new>renames the active profile;/profile <name> rename to <new>(or/profile rename <name> to <new>) renames another one. The default profile is renamable too. The profile picker’s Rename action prefills this command for you.
/profile list shows name (Display Name) when they differ, and /profile <input> resolves a directory name first, then a unique display name. A copied settings file never contains the source’s display name, profile new clears it so two profiles cannot answer to one name.
Because directory names resolve first, a rename warns you when the display name you chose will not switch back to this profile: when it matches another profile’s directory name (that directory wins), or when it duplicates another profile’s display name (the switch becomes ambiguous). The rename still applies, so use a distinct name if you want to switch by it.
Creating and managing profiles
$ veyyon profile list
$ veyyon profile new work
$ veyyon profile new dev --from dev
$ veyyon profile new bounty --from blank
$ veyyon profile rm work --yes
$ veyyon profile default work
newcreates~/.veyyon/profiles/<name>/agent/with the expected identity dirs (skills/,commands/, …).--from default(default) seedsconfig.yml, keybindings, MCP, skills, and other identity files from the default profile. Sessions, blobs, and databases are not copied.--from blankcreates an empty agent tree.--from devseeds a blank tree, then enables the study features. It setssession.instrumentationtoultraand enables Argot, the experimental token-shorthand codec. Use this profile when you want to runveyyon session statsor backtest a stored session. Instrumentation records structured, redacted session data: lifecycle and checkpoints, task transitions, tool and model timing, context attribution, agent-message delivery, and analysis rollups.basickeeps the elementary timing and state record;richadds context and communication detail;ultraadds fingerprints, abort state, links, routes, cache and reasoning detail, and upstream-provider details.rmrejects the default profile, the active profile, and destructive deletes without--yes. If you remove the profile that is set as the launch default, itsdefaultProfilepointer is cleared at the same time, so the next launch falls back to the default profile instead of a directory that no longer exists.default [name]shows or sets the globaldefaultProfile(which profile a bareveylaunches);default --clearremoves it.
Reading the size in profile list --json
veyyon profile list --json prints one object per profile. Two of its fields describe disk usage:
$ veyyon profile list --json
[
{
"name": "work",
"rootDir": "/home/you/.veyyon/profiles/work",
"bytes": 41238904,
"bytesComplete": true
}
]
bytes is the total size of every file under rootDir. bytesComplete reports whether that total is the whole story. The walk skips anything it cannot read rather than failing the listing, so a directory with no read permission would otherwise make a large profile look small. When a path is skipped, bytesComplete is false, bytes becomes a lower bound, and the skipped paths are written to the profile’s log with the message Profile size is incomplete; some paths could not be read. Check that log when you need to know which path to fix.
A profile whose directory does not exist yet reports "bytes": 0 with "bytesComplete": true: there is nothing there, which is a measurement rather than a failure.
In the TUI, /profile new <name> (or /profile create <name>) opens a picker listing every carry-over item (AGENTS.md, settings, MCP servers, SSH targets, skills, commands, tools, prompts, themes, extensions, keybindings), each individually toggleable (all selected by default). The new profile is seeded from the active profile with exactly the chosen items. Deleting is available too: /profile rm <name> (or the picker’s Delete action) removes a profile after a confirmation, and rejects the active and default profiles. See TUI profile commands for the full verb list.
The instruction row copies only AGENTS.md. A profile switch never carries RULES.md; change it through settings or copy it manually when that is your intent.
You can still create a profile implicitly by running veyyon --profile <name> once; use profile new when you want seeding without launching the TUI.
Onboarding import
On the first interactive run of a profile that has not completed setup, the setup wizard scans the machine for user-level config written for other tools (skills and CLAUDE.md/AGENTS.md from Claude Code, Codex, Cursor, and similar) and offers each item for import into the active profile. Imports copy: skills land in the profile’s skills/, instruction files append to the profile’s AGENTS.md under a source marker (re-imports are idempotent). The scan runs no matter how discovery.importForeignConfig is set, because importing is how foreign config comes in by default: ambient loading of the originals stays off unless you turn that setting on.
Do not document inline [profiles.<name>] tables or standalone <name>.config.yml files as shipped; settings use config.yml under the active agent dir.
Model policies and roles (per profile)
Each profile’s config.yml defines its interactive default, optional roles, subagent policy, and compaction policy:
modelRoles:
default: openai/gpt-5 # interactive (also set live with /model)
plan: openai/o3
smol: deepseek/deepseek-chat
subagent:
model: deepseek/deepseek-chat # blanket model chain for subagents
thinkingLevel: high
agents:
scout:
enabled: false
reviewer:
thinkingLevel: auto
compaction:
model: openai/gpt-5-mini
strategy: summary
threshold: "80%"
Unset roles and model chains inherit the live main model at use time, so switching with /model changes them immediately. Per-agent subagent settings override the blanket subagent model and effort; an unset per-agent value falls back to that blanket policy. Only an explicit assignment pins a different model. Switching profiles switches all of these assignments with the profile.
See also
Models, roles, and profiles
Concepts
| Concept | Meaning |
|---|---|
| Default model | The model used for the main conversation, and the one a new session starts on. Chosen with /model or --model, or in /settings under Model. Persisted under modelRoles.default, which is a slot rather than a selectable role, so it does not appear in role pickers. |
| Role | A named model assignment for a kind of work (smol, plan, advisor, and others). Configure it in modelRoles or Settings → Model → Roles. |
| Subagent policy | The blanket model and effort plus per-agent enabled, model, thinkingLevel, and maxNestedSpawnDepth choices under subagent. |
| Profile | User config tree at ~/.veyyon/profiles/<name>/ (including default). |
Interactive model
- Set live with
/modelor the model picker; set for a run with--model <provider/id>. - On “set as default” / persist paths, the value is stored as
modelRoles.defaultin the active profile’sconfig.yml. - There is no separate top-level
model:settings key in the schema. Prefer the picker ormodelRoles.defaultin config.
# ~/.veyyon/profiles/default/agent/config.yml
modelRoles:
default: anthropic/claude-sonnet-5 # interactive model (persisted default)
smol: openai/gpt-4.1-mini
slow: anthropic/claude-opus-4-5:high
plan: anthropic/claude-sonnet-5
advisor: anthropic/claude-sonnet-5:medium
Role values may include a thinking suffix (:off, :auto, :minimal, :low, :medium, :high, :xhigh, :max). The model picker shows only variants the selected model supports. If a provider publishes effort tiers as separate upstream IDs, Veyyon presents one logical model and routes the chosen effort to the matching ID.
Built-in roles
From packages/coding-agent/src/config/model-roles.ts:
| Role id | UI name | Notes |
|---|---|---|
default | (hidden) | Storage key for the interactive model only; not shown in role pickers or default cycleOrder |
smol | Fast | Cheap / fast work; --smol or env VEYYON_SMOL_MODEL |
slow | Thinking | Heavier reasoning; --slow or env VEYYON_SLOW_MODEL |
vision | Vision | Multimodal work |
plan | Architect | Plan mode; --plan or env VEYYON_PLAN_MODEL |
designer | Designer | Design-oriented work |
commit | Commit | Commit / changelog generation |
tiny | Tiny | Lightweight background work: titles, classifiers |
advisor | Advisor | Advisor runtime; set in Settings → Model → Advisor → Advisor Model, not in the Roles table |
Custom role names can appear via modelRoles, modelTags, or cycleOrder entries.
Unset selectable roles, advisor included, inherit the live interactive model at use time. No role has a built-in model chain: a role you have not set specifies no model of its own, so nothing you did not choose ends up running.
The Roles table lists smol, slow, vision, plan, designer, commit and tiny. advisor is a real slot with the same resolution, and @advisor names it, but it is edited from the Advisor group instead, beside the toggle that turns the feature on.
A caller may still request several roles in order. Title generation, for example, requests tiny, then commit, then smol, and takes the first one you have set. That order belongs to the caller, not to the role: tiny does not fall back to smol, the title generator prefers tiny and accepts smol. If you have set none of them, the whole list is unset and the caller inherits the interactive model like any other unset role.
There is no task role. The model your subagents run is set in the Subagents settings area, which sets it on its own; see Settings: Subagents.
To return an assigned role or model policy to its unset state, open its picker in /settings and choose the first row, (inherit main model) (the default model’s picker reads (auto-select on launch)). Del or Backspace with an empty search does the same.
Subagent policy and compaction overrides
| Setting | Effect |
|---|---|
subagent.sharedModel | Which scope decides a subagent’s model and effort. Off, each agent’s own row does. On, the two rows below do, for every agent. |
subagent.model | Ordered model chain every subagent runs while subagent.sharedModel is on. The first entry is primary and later entries are fallbacks. Unset runs every agent on the default model role. |
subagent.thinkingLevel | Effort every subagent runs at while subagent.sharedModel is on. |
subagent.agents | Per-agent enabled, model, thinkingLevel, and maxNestedSpawnDepth choices. The model and thinkingLevel on a row decide while subagent.sharedModel is off, and are not read while it is on. |
subagent.delegation | How strongly the model is prompted to delegate: allowed, preferred, or required. |
compaction.model | Ordered model chain for compaction. Unset inherits the interactive model. |
subagent:
sharedModel: true
model: deepseek/deepseek-chat:high,anthropic/claude-sonnet-5:low
agents:
scout:
enabled: false
reviewer:
thinkingLevel: auto
compaction:
model: openai/gpt-5-mini:low,anthropic/claude-haiku-4-5
strategy: summary
threshold: "80%"
In Settings, Enter edits the highlighted chain position. Add fallback appends a position. Delete removes only the highlighted position.
Cycling roles (Ctrl+P)
cycleOrder lists which roles the model switcher cycles (app.model.cycleForward / cycleBackward, default chords often Ctrl+P / Shift+Ctrl+P).
- Schema default:
["smol", "slow"](seeDEFAULT_CYCLE_ORDER). - The string
defaultis stripped fromcycleOrderon load; the interactive model is not cycled as a role entry. - Scoped models (
--models/ enabled model list) can also drive cycling when configured.
cycleOrder:
- smol
- slow
- plan
Profiles
Every profile including default:
~/.veyyon/profiles/<name>/agent/ # config.yml, sessions, MCP, skills, …
Instruction Files: Global vs Per-Profile (AGENTS.md)
Veyyon discovers exactly two user-level instruction layers before every session:
- Global User Layer (
~/.veyyon/AGENTS.md): Applies across EVERY profile and workspace. Reserved for cross-profile standing rules. - Active Profile Layer (
~/.veyyon/profiles/<profile_name>/...): Applies ONLY to the active profile. Scanned in descending priority order (first match wins; exactly 1 file loaded per profile to prevent duplication):~/.veyyon/profiles/<name>/agent/AGENTS.md(Highest)~/.veyyon/profiles/<name>/AGENTS.md~/.veyyon/profiles/<name>/agent/agent.md~/.veyyon/profiles/<name>/agent.md(Lowest)
Global ~/.veyyon/config.yml holds cross-profile keys such as defaultProfile.
Activate: --profile, VEYYON_PROFILE, veyyon profile default <name>, TUI /profiles picker or /profile <name> (relaunch).
See Profiles, File locations.
Approvals
tools.approvalMode: plan | ask | ask-command | auto | yolo (schema default auto).
Aliases: always-ask → ask, write and auto-edit → ask-command.
See Approvals.
Related
- Models and providers
- Settings: models (repo
docs/handbook/src/reference/settings.md) - Compaction
Personalities
Configure personality via /settings or the personality key in config.yml (below).
Personalities change how the agent writes replies, not which tools it has or tools.approvalMode.
Built-in personalities
A personality injects a <personality> block into the system prompt when enabled, changing tone only.
| Personality | Config value | Effect |
|---|---|---|
| Default | default | Built-in default system personality text |
| Pragmatic | pragmatic | Concise, task-focused prompt text |
| Friendly | friendly | Collaborative prompt text |
| None | none | No personality block |
Schema default: personality: default in settings-schema.ts. The setting is a free-form string, not a closed enum, see Extending the catalog.
Configuring personality
- Settings UI:
/settings→ Model tab → Prompt group → Personality. Options are resolved at render time (built-ins + your~/.veyyon/personalitiesand project.veyyon/personalitiesfiles); changing the value refreshes the base system prompt immediately. - Config file: in
~/.veyyon/profiles/default/agent/config.yml(or profile agent dir):
personality: pragmatic
There is no /personality slash command in the shipped registry. Subagents use none regardless of the main setting (sdk.ts).
Extending the catalog
The 3 shipped personalities are seeds, not a closed set. Add a <name>.md file and its filename stem becomes a selectable personality name; the file body is injected verbatim as the <personality> block:
- User-level:
~/.veyyon/personalities/<name>.md: available in every project. - Project-level:
.veyyon/personalities/<name>.md: available only in that project, and overrides a user or built-in personality of the same name.
Precedence for a given name is project > user > built-in. For example, dropping ~/.veyyon/personalities/pirate.md with the body You speak like a pirate. and setting personality: pirate renders <personality>You speak like a pirate.</personality> with no rebuild. A project .veyyon/personalities/default.md overrides the built-in default for that project only.
Edge cases:
noneis a reserved sentinel that disables the block; a file literally namednone.mdis ignored (it can never shadow the disable behavior).- An empty or whitespace-only personality file is treated as absent: the next tier (or the built-in) is used instead, so the block is never emitted empty.
- Setting
personalityto a name that resolves to nothing (no built-in, user, or project file) falls back todefaultand prints a visible warning; the<personality>block is never silently emitted empty for a real (non-none) request.
See packages/coding-agent/src/personality/resolver.ts for the resolver implementation.
Boundaries
- Personality does not grant tools, change
tools.approvalMode, or bypass sandboxing. - Personality text is escaped and injected as a bounded system-prompt section; it cannot override project rules or tool policy.
See also
- Configuration
- System prompt customization (engine doc)
Speech
Three surfaces share a local neural TTS engine (Kokoro-82M, ~100 MB, WAV/PCM). Network is used only for the first model download (and for optional remote TTS backends when configured):
- Spoken replies: the assistant’s streaming output is vocalized through the speakers as it arrives.
- Voice input: hold Space to talk; speech-to-text feeds the composer.
- Speech synthesis: the
ttsagent tool and theveyyon sayCLI turn text into audio files or playback.
Setup
veyyon setup speech
Installs audio dependencies and downloads the local TTS model into the tiny-models cache.
veyyon say
veyyon say "hello world" # play through the speakers
veyyon say --file notes.md # speak a file
git log -1 --format=%s | veyyon say # speak piped stdin
veyyon say "hello" --out hello.wav # write a WAV instead of playing
veyyon say --voices # list models and voices
veyyon say "hello" --voice bm_fable # pick a voice for this run
Long input is segmented into sentence-sized chunks and streamed gaplessly, so arbitrarily long text works. Error paths exit non-zero.
Spoken replies
Enable with the speech.enabled setting (Settings → Providers → Services).
Related settings:
| Setting | What it does |
|---|---|
speech.enabled | Speak the assistant’s output aloud as it streams. |
speech.mode | What to speak: all (messages + thinking), assistant (messages only), or yield (final message only). |
speech.voice | Kokoro voice used for spoken replies. |
speech.enhanced | Rewrite output into natural spoken prose with the tiny model before synthesis (describes code, drops links/markdown). |
Speech pauses automatically while you are talking (push-to-talk), so the assistant does not talk over you.
Voice input
| Setting | What it does |
|---|---|
stt.enabled | Enable microphone speech-to-text (hold Space to talk, or bind app.stt.toggle). |
stt.language | Recognition language (default en). |
stt.modelName | On-device STT model. |
Synthesis backend
| Setting | What it does |
|---|---|
providers.tts | Backend for the tts tool: auto (prefer local, route .mp3 to xAI when credentials exist), local (Kokoro, WAV/PCM), or xai (Grok Voice, needs xAI credentials). |
tts.localModel | Local TTS model (kokoro). |
tts.localVoice | Default Kokoro voice (see veyyon say --voices). |
speechgen.enabled | Enable the tts agent tool for speech-file synthesis. |
Cache and worker recovery
Corrupt or incomplete TTS model caches are detected and re-downloaded. The synthesis worker restarts on failure so veyyon say and the tts tool can continue after a bad download.
Export and import
Session export
/export [path] writes the current session transcript as a standalone offline HTML file. With no
argument it writes veyyon-session-<session>.html in the working directory.
The path is used as given (relative paths resolve against the process working directory). There is no
~ expansion or directory fallback, so pass a full file path ending in .html.
Migration from Claude Code
/import is not in the builtin slash registry; Claude migration runs through the setup wizard’s
import scene. It offers user-level foreign skills and CLAUDE.md/AGENTS.md instruction files and
copies the selected ones into the active profile.
Typical migrated items:
- Skills → the active profile’s
skillsdirectory (~/.veyyon/profiles/<profile>/agent/skills) CLAUDE.md/AGENTS.mdcontent → appended to the profileAGENTS.mdunder an<!-- imported from … -->marker
Ambient loading of foreign .claude configuration is a separate opt-in (discovery.importForeignConfig,
default off).
See Migration guide.
Configuration
Settings grouped by task. For provider and sign-in setup, see Models and providers and Authentication. For the full list of every key, see the repository’s docs/handbook/src/reference/settings.md.
Where settings live
Settings are YAML mappings. Persistent settings live in config.yml; custom model providers live in
models.yml; MCP servers live in mcp.json.
| Scope | Path | Notes |
|---|---|---|
| Profile | ~/.veyyon/profiles/<name>/agent/config.yml | The main persistent file. /settings and veyyon config set write here. |
| Machine-global | ~/.veyyon/config.yml | The few keys shared by every profile (defaultProfile, profileSharing, auth-broker keys). |
| CLI overlay | any file passed with --config <file> | Process-local, repeatable, never persisted. |
A repository cannot carry settings: a checked-in .veyyon/config.yml is not read. When one
repository needs different behavior, pass an overlay (--config ./repo.yml) or use a path-scoped
enabledModels / disabledProviders entry in your profile config.
Precedence, low to high:
defaults <- profile config <- --config overlays <- runtime flags
Read and write from a shell with veyyon config:
$ veyyon config list # all settings with effective values
$ veyyon config get tools.approvalMode
$ veyyon config set compaction.strategy summary
$ veyyon config path # print the active agent directory
/settings does the same inside a live session. Keys must match a schema path exactly
(theme.dark, not theme).
Session Working Directory (session.workdir vs set_cwd)
- Persistent Profile Default (
session.workdir): Configures the default working directory for a profile across all future sessions. Set interactively via/settings(Interaction › Profile) or in~/.veyyon/profiles/<profile>/agent/config.yml. - Ephemeral Session Re-root (
set_cwdtool //cwd): Re-roots the active session’s working directory temporarily. It never writessession.workdir. - What a re-root costs: the system prompt includes the working directory and that project’s context files and workspace tree, so a re-root rebuilds it. That rebuild invalidates the provider’s prefix prompt cache, and the next request re-reads the whole context as fresh input. It is done anyway because the alternative is worse: a frozen header tells the model it is working in the directory it just left, so it follows the previous project’s
AGENTS.mdand resolves relative paths against a directory it has moved out of. Moving is worth paying for; being lied to about where you are is not. The rebuild happens once per move, in every mode, and a re-root to the directory already in force does nothing at all.
Your comments and formatting survive a save
config.yml is yours to edit by hand, and veyyon writes to the same file when you change a
setting from /settings or veyyon config set. A save edits only the lines it needs to, so
your comments, blank lines, key order, and quoting stay as you wrote them:
# my machine runs hot
temperature: 0.7
# search
topK: 40
Change topK from the UI and the file becomes:
# my machine runs hot
temperature: 0.7
# search
topK: 60
A new setting is appended at the end, and a setting you reset has its line removed. If the key you removed had a comment above it, the comment moves to the next key rather than being deleted with it.
One exception: a file veyyon could not read at all is rewritten from scratch, because there is nothing in it to edit. Your original is preserved first, which is the case the next section describes.
The same holds for every other file veyyon writes that you also edit by hand:
| file | when veyyon writes it |
|---|---|
config.yml | you change a setting from /settings or veyyon config set |
~/.veyyon/config.yml | you set a default profile or profile sharing |
keybindings.yml | a binding you wrote uses a name from an older release |
WATCHDOG.yml | you edit an advisor from the dashboard |
A keybindings.yml using older binding names is the interesting one, because veyyon renames
those names for you on the next launch. The rename happens where the binding already sits, so
this:
# hold this one, muscle memory
interrupt: ctrl+x
becomes this, and not a file with the binding moved to the bottom and the comment left behind:
# hold this one, muscle memory
app.interrupt: ctrl+x
Copying a profile follows the same rule. veyyon profile new <new> --from <old> copies the
old profile’s config.yml and removes one key from the copy, profile.displayName, so the new
profile does not claim the old one’s name. Everything else in the file, comments included,
arrives exactly as you wrote it.
When a settings file has a syntax error
If you edit a config file by hand and leave it with invalid YAML, veyyon cannot read it. It reports the file at startup and runs the session on defaults for whatever that file held:
Could not read your settings, so this session is using defaults for them:
~/.veyyon/profiles/default/agent/config.yml
original kept at ~/.veyyon/profiles/default/agent/config.yml.corrupt
Your original file is copied to <name>.corrupt before anything else touches it,
so nothing is lost.
The most common cause is a value containing a colon. YAML reads that second colon as the start of a nested mapping, so this line is invalid:
statusLine: time: %H:%M
Quoting the value fixes it:
statusLine: "time: %H:%M"
Fix the syntax in the original file, or copy the preserved file back over it and edit from there.
When a setting cannot be saved
If veyyon cannot write your config file, it reports the failure in the session rather than letting the
change disappear. This happens when the file or its directory is not writable, when the disk
is full, or when something has left a directory where config.yml should be:
Could not save your settings after 3 attempts, so this change will not survive a restart:
~/.veyyon/profiles/default/agent/config.yml
EACCES: permission denied, open '~/.veyyon/profiles/default/agent/config.yml'
Check that the file and its directory are writable, then change the setting again.
The setting still applies to the session you are in, which is why the message matters: the UI shows the new value, and without it your only clue would be the setting reverting the next time you launch. Veyyon retries first and reports only when the retries have not worked, so a brief clash with another veyyon writing at the same moment stays quiet. Fix the permissions and change the setting again, and nothing further is reported.
Pick models and providers
Configure the interactive model, subagent policy, compaction model, and optional roles separately:
| Goal | What to set |
|---|---|
| Choose the model you talk to | --model / /model (persisted as modelRoles.default) |
| Run every subagent on one model and effort | subagent.sharedModel, then subagent.model and subagent.thinkingLevel |
| Customize one subagent | subagent.agents.<name> or Settings → Subagents → Agents |
| Choose the model for context compaction | compaction.model |
| Add named model assignments | modelRoles, per profile (Settings → Model → Roles) |
| Add a local or BYOK provider | a providers: entry in models.yml (see Models) |
# ~/.veyyon/profiles/default/agent/config.yml
modelRoles:
default: openai/gpt-5 # interactive model (persisted default)
smol: openai/gpt-4.1-mini
subagent:
model: deepseek/deepseek-chat
thinkingLevel: high
agents:
reviewer:
enabled: true
thinkingLevel: auto
compaction:
model: openai/gpt-5-mini # optional; else inherit interactive
/model changes the interactive model (persists to modelRoles.default when saved as default) and shows the current one. /session info shows session stats. Role list and Ctrl+P cycling: Models, roles, and profiles.
Stay safe (approvals)
| Goal | What to set |
|---|---|
| When Veyyon asks before acting | tools.approvalMode: plan, ask, ask-command, auto (default), yolo; legacy always-ask/write/auto-edit accepted |
| Per-tool policy | tools.approval: map a tool to allow / deny / prompt |
| Advisor review pass | advisor.enabled + modelRoles.advisor |
tools:
approvalMode: ask-command
approval:
bash: prompt
read: allow
Per run, --approval-mode <mode> and --auto-approve / --yolo override the mode. There is no
OS shell sandbox. The mode is the main boundary, and three guards sit on top of it: the
working-directory boundary, the secret-use boundary, and the destructive-command floor in the bash
guard. The first two hold on every rung except yolo, the shipped auto included. The floor holds
on yolo as well, and only tools.approval.bash: allow lifts it. See
Approvals and Safety.
Run unattended or in CI
| Goal | What to pass |
|---|---|
| Non-interactive one-shot | veyyon --print "…" (prompt as arg or piped stdin), on the default auto rung so no tier prompt can stall the run |
Force tools.approvalMode: yolo for the run | --yolo |
| Temporary settings for one run | --config ./ci-settings.yml (repeatable) |
Lifecycle automation inside sessions uses hooks.
Control context, memory, and compaction
Compaction compresses older history instead of truncating it. Common keys:
| Goal | What to set |
|---|---|
| Auto-compaction threshold | compaction.threshold: auto, a percent (85%), or a token amount (170000) |
| Compaction type | compaction.strategy: summary, the sole strategy |
| Compaction model | compaction.model (unset = interactive model) |
| Cross-session memory backend | memory.backend: off (default), local, hindsight, mnemopi |
compaction:
threshold: "80%"
strategy: summary
model: openai/gpt-5-mini
memory:
backend: mnemopi
See Compaction and project memory and Memory.
Save tokens with project shorthand (Argot, experimental)
A project accumulates long strings that recur in its work: file paths, import
roots, canonical build commands. Argot lets the model write a short handle in
their place. The handle is § followed by a name, for example §dbconn. veyyon
expands every handle back to its full text before anything outside the model’s
own history sees it, so tools receive the real string and the display shows the
real string. The short handle is what stays in the conversation, which is where
the token saving comes from.
Turn it on with one setting:
argot:
enabled: true
The default is false. You do not write or commit any dictionary. When Argot is
on and the model is allowed to write shorthand (see the next section), veyyon
loads the folder you started the session in, and the system prompt teaches the
model the notation and gives it two tools, argot_load and argot_unload, so it
can load further projects itself. You can turn the startup load off and leave
every load to the agent, described under
Choose when a project is loaded below.
Loading a folder resolves it to its project root (the nearest .git, or a
.argot marker for a project with no git), reads the project’s files (the ones
git tracks, or a walk of the tree for a .argot project), proposes handles for
the strings that would save the most tokens, and keeps the result in a local
cache under its own config directory. In a monorepo the agent loads the one
package it works in, not the repo root. Loading reads a project tree and writes
the cache, so in the approval-gated autonomy modes veyyon prompts before running
it and shows the resolved root; unloading never needs approval, because it only
teaches less and every handle already written keeps expanding.
Nothing is written to the working tree, so there is no file for a pull request
to pick up. Each cache entry is immutable and named by the content it was built
from (the git commit for a git project, or a signature of the file listing for a
project with a .argot marker). A new commit reads a new entry, built from the
new tree; the old entry is never rewritten. Nothing depends on a handle keeping
its name across states, because veyyon expands every handle before it reaches the
saved transcript, so an entry never has to agree with an older one. Once a
project is loaded, veyyon lists its handles in the system prompt, and the model
writes them from then on. A session where nothing is ever loaded simply writes
full strings, exactly as if Argot were off.
You never see a handle
Shorthand is for the model, not for you. Everywhere veyyon shows you what the
model wrote, it shows the full text. If the model writes §conn, you read the
full path it stands for, such as “packages/server/src/database/connection.ts”.
That holds for every surface, not only the reply text:
- the answer as it streams in, and the finished message
- the model’s reasoning, when you have thinking blocks visible
- a tool call’s arguments, including the file body of a
writewhile it is still being typed out - the one-line intent shown beside a running tool
- the transcript after you resize the window, change theme, or resume the session later
--printoutput and an exported or shared session
The saving is real all the same, because the short form is what stays in the conversation the model rereads. You are reading an expanded copy; the model is reading the handles. A handle is only ever expanded for display, so nothing you see depends on the dictionary still being loaded.
One detail is worth knowing if you watch closely. While a handle is arriving,
veyyon holds back the last few characters rather than showing you a partial
§co that is about to become something else. The text catches up on the next
chunk. You may notice a word appearing a fraction later; you will not see a
handle.
Choose which models write shorthand
Enabling Argot alone does not make any model write handles. You also list the models allowed to do so:
argot:
enabled: true
encode:
models:
- anthropic/claude-opus-4
A model on this list is taught the notation; a model left off never is. The list is empty by default, so turning Argot on without setting a model stays inert. This lets you keep shorthand on for a model you trust to recall the dictionary and off for one you are still measuring. Expansion never depends on this list: a handle already written expands whatever model is active, so switching models never leaves a raw handle behind.
The two settings under encode are the two that decide whether a model is taught
to write shorthand: this list, and the context cutoff described below. Everything
else about Argot sits directly under argot, because it sets whether the
feature runs, when a dictionary is built, how large it is, and what a subagent
starts with. The split is there to make one thing obvious: nothing under encode
affects reading. A handle already in the conversation expands whatever these hold.
If you have argot.models or argot.disableAboveTokens in a config from an
earlier version, you do not have to change anything. veyyon moves them under
encode the first time it reads the file, keeps the value, and drops the old key
the next time it saves.
Choose when a project is loaded
A dictionary has to be built before any handle exists, and there are two ways
that happens. veyyon loads the folder you started the session in, once, in the
background as the session comes up; and the model loads any further project it
moves into by calling argot_load itself. The first of those is what
argot.autoload controls:
argot:
enabled: true
encode:
models:
- anthropic/claude-opus-4
autoload: false
The default is true, so the project you launched in is ready without the model
spending a turn on it. Set it to false when you want every load to be a
deliberate act by the agent: a session then starts with no dictionary, and stays
that way until the model calls argot_load. That is the setting to reach for on
a machine where the first walk of a very large repository is expensive enough
that you would rather pay it only when shorthand is actually wanted.
Turning it off changes when a dictionary is built, never whether a handle
expands. The startup load runs in the background, so a session never waits on it
either way; when it finishes it refreshes the system prompt to teach the handles,
which is the same thing argot_load does.
Size the dictionary
The generated dictionary is packed under a token budget: handles are added in
value order until the next one would breach it, so the budget sets how many
strings earn shorthand. A larger budget teaches more handles, which gives the
model more chances to save tokens in its writing, but it also makes the notation
preamble longer every turn. A smaller budget keeps the preamble cheap and teaches
only the most central strings. Set it with argot.tokenBudget:
argot:
enabled: true
encode:
models:
- anthropic/claude-opus-4
tokenBudget: 2000
The default is 1000. Changing the budget generates a new dictionary: the cache
key folds in the budget, so an entry built under one budget is never reused for
another, and the old entry is left in place. A value that is not a positive
number is rejected and the default is used, so a bad setting never quietly
produces an empty dictionary.
Stop shorthand in a large context
Recall of the dictionary degrades as a conversation grows. To bound that risk, stop teaching shorthand once the context passes a token threshold:
argot:
enabled: true
encode:
models:
- anthropic/claude-opus-4
disableAboveTokens: 400000
Past the threshold the model writes in full instead of risking a garbled handle.
Handles written earlier still expand losslessly, because the cutoff stops only
the teaching, never the expansion. The default is -1, which never stops on
size.
Choose how subagents start
A subagent (a child veyyon spawns for a task) can start with its own shorthand,
or none. Set that with argot.subagents:
argot:
enabled: true
encode:
models:
- anthropic/claude-opus-4
subagents: fresh
The three values are:
off(the default): a subagent gets no shorthand. It reads full text and writes full text.fresh: a subagent gets its own shorthand session and loads the project of its own task throughargot_load, independent of the parent. Use this when a subagent works a different project than its parent, for example a parent in a monorepo and a child scoped to one crate.inherit: a subagent starts from a copy of the parent’s loaded shorthand, so it writes the parent’s handles from its first turn.
This setting only trades tokens; it never changes what the agents agree on. Every
agent expands its own output before it reaches a tool, the saved transcript, a
prompt it hands to a child, or the result it returns to a parent, so a handle
never crosses between a parent and a child in either direction. A subagent that
starts with no shorthand is already correct: it simply writes in full. That is why
off is a safe default and the other two are optimizations.
The generated cache is per project and local to your machine. To rebuild it from
scratch, delete the project’s cache directory under veyyon’s config root; the
next argot_load regenerates it.
Restrict tools for a repo or role
Deny a tool with per-tool policy, or disable a built-in tool entirely:
tools:
approval:
bash: deny
edit: deny
bash:
enabled: false
Plan mode and agent definitions can narrow the tool set further. enabled: false removes the tool from both
the model-visible set and the dispatch registry; tools.approval.*: deny keeps the tool visible but
rejects every call with an error stating the policy.
Set the default working directory
Each profile can pin a default session working directory so launches from $HOME
(or any other directory) still root tools at the right project:
| Goal | What to set |
|---|---|
| Per-profile default cwd | session.workdir (absolute or ~-relative path) |
| One-shot override for this launch | --cwd <path> |
Launch precedence for the session cwd, highest first:
explicit --cwd > session.workdir > process cwd
# ~/.veyyon/profiles/work/agent/config.yml
session:
workdir: ~/src/veyyon
$ veyyon config set session.workdir ~/src/veyyon
$ veyyon --cwd /tmp/scratch # wins over session.workdir for this run
session.workdir must resolve to an existing directory; a relative path or a
missing directory fails launch rather than falling back silently. Mid-session
overrides via the agent set_cwd tool or /cwd are session-scoped only: they
re-root the live session (the cwd, and with it the path-scoped settings,
secrets, capabilities, ssh tool, and system-prompt project framing) and
never write session.workdir. Persist a new default with veyyon config set or
/settings.
Profiles
Each profile is ~/.veyyon/profiles/<name>/agent/ (including default). Activate with --profile <name> (-p is --print, not profile), VEYYON_PROFILE, or TUI /profile (relaunch).
$ veyyon --profile work
$ # edit ~/.veyyon/profiles/work/agent/config.yml
See Profiles, File locations.
Wire MCP servers and hooks
MCP servers are configured as JSON, not in config.yml:
In ~/.veyyon/profiles/default/agent/mcp.json (JSON is strict, no comments):
{
"mcpServers": {
"database": {
"type": "stdio",
"command": "node",
"args": ["/path/to/db-mcp-server/index.js"]
}
}
}
Hooks: TypeScript modules under project/profile hook paths (pi.on(...)). See Hooks, Task guides. MCP: MCP.
Related
- Getting started
- Task guides
- Safety:
tools.approvalMode(defaultauto) - Extending
- CLI
Feature flags
Veyyon gates optional behavior in two places: features.* keys in the settings schema, and
per-plugin feature gates managed with the plugin subcommand.
Settings-schema flags (features.*)
Registered flags live in config.yml under dotted features.* keys and appear in
Settings › Interaction in the TUI. Unknown keys are preserved verbatim (they may belong to a
newer build or another tool); only schema-declared keys are type-checked.
| Key | Default | Effect |
|---|---|---|
features.unexpectedStopDetection | off | Use a small model to detect when the assistant says it will continue but stops without tool calls, and automatically prompt it to continue. |
features:
unexpectedStopDetection: true
Per-run override: put the same YAML in a file and load it as an overlay:
$ veyyon --config ./flag-overlay.yml "long refactor; keep going until done"
Plugin feature gates
Plugins can declare named features that you toggle per plugin:
$ veyyon plugin features <plugin> # list a plugin's features
$ veyyon plugin features <plugin> --enable f1,f2 # turn features on
$ veyyon plugin features <plugin> --disable f1 # turn features off
$ veyyon plugin features <plugin> --set f1,f2 # replace the enabled set
See Plugins for installation and management.
Related
Tool approvals are not feature flags, they use tools.approvalMode and per-tool policy.
See Approvals.
Tools, skills, and extension data
Tools
Built-in tools (read, search, edit, bash, …) run through the agent loop under tools.approvalMode and related policy. Edit paths share hashline verification. Catalog: Tools reference.
Skills
Filesystem skill packages (e.g. SKILL.md trees) discovered under the active profile’s skill dir. A project-local skills directory is never scanned. Unreadable or malformed skill files surface load warnings. See Skills.
Plugins
veyyon plugin / marketplaces install bundles of skills, MCP, hooks, and related assets. See Plugins.
MCP
External tools via Model Context Protocol: client config in the profile’s mcp.json (there is no project scope). See MCP.
Hooks
TypeScript modules with pi.on(...), not JSON command tables. See Hooks.
Related
- Configuration: approval mode and settings paths
- Mechanisms
Migration guide
This guide walks through upgrading Veyyon and recovering when an upgrade does not go as planned. Veyyon stores all user data under the config home (~/.veyyon on Unix by default, the Veyyon application directory on Windows; relocatable with VEYYON_CONFIG_DIR), so most upgrades are safe if you back up that directory first.
Before you upgrade
-
Close all running Veyyon sessions and TUI instances. Writes may still happen while the binary is running, and a backup taken during activity can be inconsistent.
-
Back up the config home:
cp -R ~/.veyyon ~/.veyyon-backup-$(date +%Y%m%d)Keep this backup until you have verified the new version with
veyyon plugin doctorand completed one normal session. -
Read the release notes for the version you are installing. They list required config changes, renamed fields, and any new dependencies.
Config schema updates
Profile settings live in ~/.veyyon/profiles/<name>/agent/config.yml (default profile: profiles/default/agent/config.yml). Global cross-profile keys (for example defaultProfile) live in ~/.veyyon/config.yml. The binary validates settings against the schema and reports the file, the dotted setting key, and the reason on failure.
Common schema changes
- New keys arrive with a schema default, so a missing key is never an error; the default applies until you set your own value.
- Renamed fields are migrated automatically where a migration exists, and the file is rewritten in the new spelling.
- Removed or unknown fields are preserved silently and never block startup (they may belong to a newer build or another tool). Delete them yourself to keep the file clean.
Updating your config
-
Edit
~/.veyyon/profiles/default/agent/config.yml(or the active profile agent dir). Use~/.veyyon/config.ymlonly for global keys such asdefaultProfile. -
Run the new binary once to see any validation errors:
veyyon --version veyyon plugin doctor -
Fix each reported line. If you are unsure what a key does, see Configuration and File locations.
-
After editing, run
veyyon plugin doctoragain to confirm the file loads cleanly.
You do not need to rewrite the whole file. Most upgrades only add or rename a few keys, and the rest of your settings stay the same.
Session and state data
Session data lives under the profile agent dir (~/.veyyon/profiles/default/agent/ by default):
sessions/: append-only JSONL rollouts (conversation history, branching).- SQLite stores under the agent dir (for example
history.db,agent.db) mirror lookups; they can be rebuilt from rollouts when missing.
This means you usually do not need a manual database migration. When you start the new binary, it reads the rollout files and updates the state database as needed. If you see a warning about a stale state database, the binary repairs it automatically on startup.
If you need to force a state rebuild
- Close Veyyon.
- Remove the state database file (see File locations for the exact path on your platform).
- Restart Veyyon. Indexes rebuild from agent-dir
sessions/rollouts.
Never delete sessions/ to fix a state problem. Rollouts are the durable history; SQLite stores under the agent dir are caches/indexes.
Rolling back a binary
If the new binary does not work for you, you can go back to the previous version without losing data.
- Close all Veyyon processes.
- Restore the previous binary. Run
veyyon rollbackto pick an earlier version, or re-run thecurlinstaller with--ref v<version>to pin that release binary (fetched from GitHub Releases). If you run Veyyon out of your own git checkout instead, go back withgit checkout v<version>and rebuild. - Restore profile
config.yml(and global~/.veyyon/config.ymlif you changed it) from the backup you made before upgrading, if the new version modified settings the old version cannot read. - Leave agent-dir
sessions/, archives, and SQLite stores in place. Rollout files are forward-compatible for recent releases; the old binary can rebuild indexes when needed. - Start Veyyon and run
veyyon plugin doctorto confirm the environment is healthy.
If you used a new feature that wrote settings the old binary does not recognize, remove or rename those keys before starting the old binary. The error message will point you to the right lines.
Checking health after an upgrade
After every upgrade, confirm the install is healthy:
veyyon --version
veyyon plugin doctor
veyyon plugin doctor checks plugin installation health (directories, manifests, entry paths, enabled
features); it exits non-zero when a check reports an error. Binary and provider-key checks live in
veyyon setup status. Start a normal interactive session and run
/debug and /memory diagnose to confirm the runtime and memory backend are working.
Treat every failed check as actionable. Fix the reported line, then re-run. If a check fails after a
rollback, compare your config.yml against the backup from before the upgrade. See
Troubleshooting for the common failure modes and
Diagnostics and health for the full diagnostics surface (veyyon plugin doctor, TUI /debug).
Where to go next
- Configuration explains the settings that change between releases.
- File locations lists every path under the config home.
- Troubleshooting walks through common upgrade failures.
- Diagnostics and health covers the diagnostics surface in detail.
Reference
This part is the lookup desk: command flags, environment variables, exit codes, and the files Veyyon creates on your system. Use it when you need the exact name of a flag, the location of a config file, or the meaning of a return code.
- CLI reference: the
veyyoncommand and every subcommand, plus common flags and config overrides. - Slash commands: every
/command available inside an interactive session, grouped by task. - Tools reference: model-facing tools (read/edit/write, search, bash/eval, browser, MCP resource access) with params and safety.
- Environment variables: supported common variables grouped by purpose (location, auth, catalog, TLS, install, repair, terminal), plus descoped names.
- Exit codes: the return codes Veyyon uses and how child-process exit status passes through.
- File locations: where Veyyon stores config, sessions, logs, and credentials under
~/.veyyon.
Where to go next: if something is failing, see Troubleshooting; for definitions of terms used throughout the book, see the Glossary.
CLI reference
The command is veyyon. Run veyyon with no subcommand to start an interactive session; use a
registered subcommand for everything else. veyyon --help and per-command --help are the
generated source of truth.
Starting a session
$ veyyon
$ veyyon "fix the failing test in auth.rs"
Common launch options:
| Option | Purpose |
|---|---|
[PROMPT] | Optional initial user prompt |
-c, --continue | Continue the previous session |
--config <file> | Load an extra config.yml-style overlay for this run (repeatable) |
--approval-mode <policy> | When to ask before running commands |
--profile <name> | Use an isolated profile agent directory |
--model <id> | Interactive model (provider/model) |
--compaction-model <id> | Model for context compaction |
Config precedence: CLI flags → --config overlays → profile config → defaults. See
Configuration.
Registered subcommands
Unknown first tokens route to launch as a prompt:
| Command | Aliases | Purpose |
|---|---|---|
launch | (default) | Interactive or prompted session |
acp | Agent Client Protocol server mode | |
agents | Manage agent definitions | |
auth-broker | Shared auth broker (headless login) | |
auth-gateway | Auth gateway helper | |
bench/throughput | Throughput benchmark harness | |
commit | Agentic commit workflow | |
completions | Shell completion scripts | |
config | List/get/set settings | |
dry-balance | Dry-run OAuth account balancing | |
gc | Garbage-collect session artifacts | |
grep | Grep-tool CLI probe | |
gallery | TUI gallery / fixtures | |
grievances | Internal grievance reporter | |
install | Install or link an extension package (alias of plugin install/plugin link) | |
join | Join collab session | |
licenses | Print Veyyon and third-party license notices | |
models | List models and providers | |
plugin | Plugin lifecycle (list, install, …) | |
profile | profiles | List, create, or remove self-contained profiles |
prompt | Inspect the assembled system prompt without starting a session | |
read | Read-tool CLI probe | |
rollback | Move this install to another published version | |
say | Speak text with local TTS (--voices lists voices) | |
search | q | Web search probe |
session | sessions | Study a stored session (stats: timing, tool cost, turn cadence) |
setup | First-run setup wizard | |
shell | Native shell probe | |
ssh | SSH host configuration | |
stats | Usage statistics dashboard (--summary prints to console, --json for machines) | |
tiny-models | On-device tiny model utilities | |
token | Print a provider’s API key or OAuth token | |
ttsr | Time-traveling stream rules test | |
trust | Decide whether this project’s code may run (Project trust) | |
update | Self-update | |
usage | Provider usage limits | |
worktree | wt | Git worktree helpers |
Hidden worker selectors and --smoke-test are for CI/packaging, not daily use.
Studying a session
veyyon session stats [id] reads a stored session and reports how it spent its
time and tokens. With no id it studies the most recent session in the current
directory; give a session id or filename prefix to pick another one. The command
reads only, so it is safe to run against a session another process is writing.
$ veyyon session stats
$ veyyon session stats 3f8a
$ veyyon session stats --json
It reports, in one pass:
- Totals: wall clock, turn and tool-call counts, token usage, request time, tool execution time, queue wait, and tool-result weight.
- Lifecycle: sequence coverage, checkpoints, and the latest running or ended state.
- Context: prompt, non-message, stored-message, and tail-token attribution when recorded.
- Agent communication: sent and received message counts, payload bytes, outcomes, and delivery routes.
- Task state: latest open, active, dropped, and completed counts plus recorded transitions.
- Tool latency and cost: per-tool execution percentiles, scheduler wait, returned tokens, and returned bytes.
- Repeated argument fingerprints: tools called more than once with the same collision-resistant argument digest. Older sessions with only 32-bit fingerprints are analyzed in a separate legacy namespace.
- Per-turn: each assistant turn’s model, request time, tool calls, and token usage.
The session.instrumentation setting controls the stored detail:
offstores the normal resumable conversation and tool history without extra telemetry. Stats still use normal assistant usage and messages.basicadds lifecycle and checkpoints, task-state transitions, tool wall-clock and status, and model request timing.richadds context attribution, agent-message delivery, tool scheduling and result weight, model token throughput, and richer rollups.ultraadds argument fingerprints, abort state, compaction links, directional agent routes, per-task transitions, cache and reasoning detail, and upstream-provider details.
The setting applies immediately. A new level starts a new measured lifecycle interval. A turn already in flight keeps the lower of its dispatch level and the current level when it is stored. If you turn instrumentation off before that turn finishes, its added study fields are omitted. Normal conversation and tool history remain resumable.
Use ultra for a session you want to study in full, or create a study profile with
veyyon profile new dev --from dev. See Profiles for the
setting and profile behavior.
--json prints the complete report, including every turn; the text view caps the
longest tables and reports it when it does.
There are no veyyon app-server, exec-server, execpolicy, or responses-api-proxy subcommands,
and no top-level resume / fork / archive verbs. Resume and branch from the TUI (/resume,
/fork, /session) or the launch session picker; for non-interactive resume use veyyon --print --resume <id> / --continue.
Exit codes
See Exit codes.
Slash commands
Slash commands run inside an interactive Veyyon session. Type / in the composer to open the
picker. Commands below are the builtin set; extensions may add more.
A command nothing can handle is rejected, not sent to the model. If you mistype a name, or type a command your installed build does not have, you get:
Unknown command "/secrt". Nothing handled it, so it was not sent to the model. Type / to see the
commands this build has, or drop the leading slash to send it as a message.
The refusal states the command and never repeats what followed it, because the tail of a mistyped
/secret is a credential. The rule reaches further than one mistyped word: in a terminal the whole
argument line of /secret is the credential, whatever it happens to spell, so there is no tail
there that is safe to echo back.
A message that merely begins with a filesystem path is prose, not a command, and is sent as usual:
/etc/hosts is broken
The separator decides. A command name is one segment of letters, digits, underscores and hyphens starting with a letter, so anything holding a slash is a path.
Every argument is a plain word
No slash command takes an option. Nothing is spelled with a dash, so there is nothing to look up and nothing to get in the wrong order. A word means something for one of two reasons: the POSITION it sits in, or a CLOSED SET or SHAPE it belongs to.
Plenty of commands take a single argument, listed with them in the tables below. These are the ones with a grammar to state, and each used to spell part of it with dashes:
/mcp add <name> [http|sse] [url <url>] [token <token>] [run <command...>]
/mcp remove <name>
/mcp smithery-search <keyword...> [<limit 1-100>] [semantic]
/ssh add <name> <host> [user <user>] [<port>] [key <keyPath>]
/ssh remove <name>
/stats [<port>]
/secret has its own grammar and its own page: see Secrets.
Position covers every required word, so /mcp remove project removes a server actually named
project. Where meaning is taken from a word’s shape instead, the sets provably cannot overlap: on
/ssh add a port is digits and nothing else the command reads is, and user and key are the only
two keywords, each taking the word after it. A word the command cannot use is rejected rather than
ignored, because a word that is silently dropped looks like a setting that was applied.
A spelling that was an option
Each of these commands remembers the option spellings it used to have, and rejects them, stating the plain word that replaced each one:
/ssh add box example.com --port 2222
--port is gone: write the port as a plain integer.
Usage: /ssh add <name> <host> [user <user>] [<port>] [key <keyPath>]
The plain word gets the same answer as the dashed one. /stats port 8080 is rejected the way
/stats --port 8080 is, and /mcp add srv project the way /mcp add srv --scope project is,
because the operator who types the word an older grammar taught is asking the same question either
way and wants the same answer. Which words those are is read from the same table the refusal text
comes from, so the two spellings cannot drift apart.
A word that never was an option is rejected more briefly, since there is no replacement to name:
Unknown argument: <word>, or Invalid port: <word> where a port was the only thing the command
reads.
/mcp smithery-search is the exception, and it is one on purpose: its trailing words are search
terms, arbitrary text with no closed set, so a plain project there is a keyword to search for and
is searched for. Only the dashed spellings are rejected.
A bare command that has subcommands
Some commands take a subcommand: /account status, /account manager, /usage reset. Typing the
command on its own opens a picker listing every subcommand it has, with what each one does. Move
with the up and down arrows, click a row, press enter to run it, or press escape to close and run
nothing. Choosing a row runs exactly what typing that subcommand runs.
A subcommand that takes an argument, such as /account name <text>, does not run straight away.
The picker writes /account name into the composer and leaves the cursor after it, so you type
the argument and press enter.
Outside a terminal, in ACP and --print mode, there is no picker to open. A bare command prints
the same list instead.
A few commands mean something on their own rather than standing for a subcommand, and those still
act on a bare invocation: /yolo, /fast, and /browser flip a switch, /goal enters goal mode,
/todo shows the list, /secret opens the masked value field, /setup opens the wizard,
/plugins lists plugins, and /compact compacts.
Session and navigation
| Command | Purpose |
|---|---|
/new, /fresh | New session (fresh may reset provider stream state) |
/resume | Resume another saved session |
/fork, /branch, /tree | Branching and session tree UI |
/rename <title> | Rename session |
/move <dir> | Relocate the session (including its saved session file) to another working directory and re-root path-scoped settings, secrets, capabilities, and the system-prompt project framing there |
/cwd [path] | Bare prints the current session cwd; with a path, re-roots the live session at that directory after validating it exists. Reloads the same cwd-scoped state as /move (path-scoped settings, secrets, capabilities, the ssh tool, system-prompt framing) but does not relocate the session file. Session-scoped only; does not write profile session.workdir |
/export [path] | Export the session as a standalone HTML file |
/dump | Dump debug artifacts |
/session info, /session delete | Session metadata or delete |
/profile [name], /profiles | Bare opens the profile picker (switch, rename, create, delete); /profile <name> switches (relaunches as a fresh session); /profile new <name> opens the copy picker; /profile <name> rename to <new> sets a display name; /profile rm <name> deletes after a confirmation |
/welcome | Show the full welcome screen (actions, recent sessions) |
/exit, /quit, /pause | Leave or pause |
Model, modes, and behavior
| Command | Purpose |
|---|---|
/model [id], /models | Select the interactive model only (no role cycle; roles live in settings) |
/switch | Try a model for this session only, without saving it as default (same as alt+p) |
/fast on|off|status | Fast mode |
/effort [level] (/thinking) | Set reasoning effort; no argument opens the picker |
/cpu-limit [status|lift|reset] (/cpu) | Report the machine and session resource limits and what is enforcing them, or lift this session’s CPU cap. Sets nothing: limits are configured in /settings under Resources |
/permissions [rung] (/approval) | Set how much the agent does unasked, for this session only: ask, ask-command, auto, yolo, or plan. /permissions status reports the rung in force and where it came from; reset drops the session override and returns to the saved default from Settings. A bare /permissions opens the picker |
/yolo on|off|status | Remove this session’s permission prompts (a blatantly destructive command, an explicit deny, and plan mode still block; needs confirmation) |
/plan | Toggle plan mode |
/plan-review | Re-open plan review |
/goal … | Goal set/show/pause/resume/drop/budget |
/guided-goal | Guided goal wizard |
/loop | Loop mode controls |
/prewalk [model] | Arm the prewalk switch for this session: the agent moves to the cheap model at its next edit or write, once the todo list exists. The target comes from the argument or prewalk.cheapModel; with neither, the command fails naming the setting |
/secret | Store a credential the agent uses by placeholder and never sees. A command comes first on every surface and every argument after it is a plain word: /secret add <value> stores it in a terminal, /secret add alone opens a hidden field, /secret from-env <VAR> reads it out of the environment, and the name is prompted afterwards, with Enter accepting the generated one. The commands are add, from-env, list, rm, clear, rename, value, scope, copy, extend, log, discard, help; a first word that is none of them is rejected and nothing is stored. See Secrets |
/settings, /setup | Settings UI; /setup opens first-run provider sign-in |
/providers, /account manager | Open the account manager: every stored account per provider, with its email, plan, health, and usage. See Authentication |
/account status | Show which account each provider is serving this session with. A bare /account opens the picker |
/account name <text> | Name the account this session is using, so rows read work instead of an email |
/account switch <provider> | Open the manager focused on one provider, to move that provider to another of your accounts |
/statusline | Settings UI, jumped to Status Line (preset/segments/separator) |
/reload-plugins | Reload extensions |
/trust | Decide whether this project’s code may run; approve, deny, forget, or a path (Project trust) |
/force <tool> [prompt] (/force:) | Force the next turn to use a specific tool |
Tools, context, and jobs
| Command | Purpose |
|---|---|
/compact [summary] [focus] | Summarize older context in place; optional focus string |
/shake elide|images | Shake tool-result bulk. A bare /shake opens the picker |
/handoff [focus] | Explicitly transfer context into a new session |
/context | Context usage report |
/tools | Tools visible to the model |
/jobs | Background async jobs |
/todo … | Todo list CRUD |
/browser … | Browser tool mode |
/memory … | Memory backend view/stats/clear/enqueue |
/copy | Pick text or code from the conversation to copy |
/rephrase | Ask for the last reply again, in plainer prose. Needs a finished reply to work from |
/lsp | Show language server status |
Auth and usage
| Command | Purpose |
|---|---|
/login [provider|url] | OAuth / API key login |
/logout [provider] | Log out |
/usage show|reset | Provider rate limits |
/stats [<port>] | Open the usage dashboard in a browser. The port is a plain integer and defaults to 3847; veyyon stats opens the same dashboard from a shell |
/changelog | Open the release notes on the web |
Extensions
| Command | Purpose |
|---|---|
/mcp … | MCP server management |
/mcp notifications | Show notification capabilities and subscriptions |
/plugins … | Plugin browser |
/extensions, /status | Extension Control Center dashboard. /status is an alias for it, not a session-status view |
/agents (aliases /cockpit, /hub) | Open the subagent dashboard: live agent roster and the agent-to-agent comms stream |
/ssh … | SSH host setup. add takes the name and host by position, then user <user>, a plain port, and key <keyPath> in any order: see Every argument is a plain word |
/hotkeys | Active keybinding chords |
/collab …, /join, /leave | Live collab sessions |
/share | Share the session via an encrypted link (share server or secret gist) |
Side agents and misc
| Command | Purpose |
|---|---|
/btw | Ephemeral side question |
/tan | Run a full background agent on tangential work |
/advisor … | Show, configure, start or stop the advisor that reviews each turn |
/omfg | Forge a TTSR rule from a complaint to stop a recurring behavior |
/vibe | Toggle vibe mode (director + vibe_* worker tools) |
/retry | Retry failed turn |
/debug | Debug overlays |
/queue | Queue follow-up message |
/drop | Delete the current session and start a new one (dequeuing a queued message is the alt+up chord, not a slash command) |
Every subcommand
The tables above write … where a command takes a subcommand. This is the full set, so a name is
findable without opening the picker. What each one does is on the picker row and in that feature’s
own page; typing the bare command lists them with their descriptions.
| Command | Subcommands |
|---|---|
/setup | providers |
/account | status, manager, switch, use, name, refresh, usage, login, logout |
/goal | set, show, pause, resume, drop |
/fast | on, off, status |
/permissions | status, ask, ask-command, auto, yolo, plan, reset |
/yolo | on, off, status |
/cpu-limit | status, lift, reset |
/secret | add, from-env, list, rm, clear, rename, value, scope, copy, extend, log, discard, help |
/collab | start, view, status, stop |
/browser | headless, visible |
/todo | edit, copy, export, import, append, start, done, drop, rm |
/session | info, delete |
/usage | show, reset |
/mcp | add, list, remove, test, reauth, unauth, enable, disable, smithery-search, smithery-login, smithery-logout, reconnect, reload, resources, prompts, notifications, help |
/ssh | add, list, remove, help |
/compact | summary |
/shake | elide, images |
/memory | view, stats, diagnose, clear, reset, enqueue, rebuild, mm list, mm show, mm refresh, mm history, mm seed, mm delete, mm reload |
/plugins | list |
/trust | approve, deny, forget |
/advisor | status, configure, on, off, dump |
Extension packages (for example swarm) register additional commands when installed. The live set is whatever the session registers; use /help or the command palette in the TUI. Status line: /statusline opens the Status Line settings group (see Multi-agent monitoring). Keybindings: /hotkeys. Memory: /memory and settings under the active memory backend.
Tools reference
Model-facing tools are advertised to the model per turn. Availability depends on settings, approval mode, plan mode, memory backend, and feature flags.
For approvals see Approvals. For MCP tools see MCP.
Per-tool engineering specs live under docs/tools/.
Core loop
- Model emits a tool call (JSON arguments per schema).
- Veyyon validates arguments; handlers run after approval checks.
- Text or structured output returns to the conversation.
General schema repair runs before dispatch on all schema-bearing tool calls; tool-specific leniency (e.g. hashline parsing) is layered on top. See Repair overview.
Edit and write
| Tool | Purpose |
|---|---|
edit | Apply changes, default hashline (edit.mode: hashline); also apply_patch / patch / replace modes |
write | Create or overwrite a whole file |
Hashline flow: read/search mint [path#TAG] anchors → model copies tags into edit →
@veyyon/hashline applies ops. See Edit engine and
docs/tools/edit.md.
Read and search
| Tool | Purpose |
|---|---|
read | Files, dirs, URLs, archives, SQLite, memory://, skill://, … |
search | Unified workspace search (files/paths, text/regex, and code structure) |
search_tool_bm25 | Discover tools by description (when enabled) |
Shell and execution
| Tool | Purpose |
|---|---|
bash | Shell commands, gated by the approval mode |
ssh | Remote commands via configured hosts |
eval | JS/Python/Julia/Ruby eval cells (when enabled) |
debug | Debugger integration |
browser | Browser automation |
job | Background job control |
Long-running and stuck commands
Two settings decide when a foreground bash call is moved to a background job. Auto-background is on by default; stall detection is off. Both hand the command to the job tool so its result still arrives later. You set them per profile in /settings, under Shell.
Bash Auto-Background caps how long a command holds the model in the foreground. Once a call runs longer than “Auto-Background After” (bash.autoBackground.thresholdMs, default 5 minutes), it moves to the background and the model keeps working. This fires on elapsed time even while the command is still printing: a test suite that takes forty minutes should not hold the model, and a long foreground command would otherwise outlast the prompt cache. Set the value to “Immediately” to background every command up front, or turn Bash Auto-Background off to let a command hold the foreground until it finishes or times out.
Turn on Bash Stall Detection to catch a command that has gone quiet. When a call produces no new output for “Stall After” (bash.stallDetection.stallMs, default 30 seconds), it is backgrounded and the model is told it may be stuck, along with the exact job cancel to run. This measures idle output, not total run time, so a command that keeps printing never trips it. The model decides: if the quiet was expected (a slow compile, a network wait), it lets the job finish; if the command is genuinely hung, it cancels it. The setting recommends, it never force-kills.
Agent coordination
| Tool | Purpose |
|---|---|
task | Spawn subagents |
irc | Inter-agent messaging |
todo | Structured task lists |
goal | Goal card updates (with goal mode) |
ask | User questions |
yield | Yield turn for follow-ups |
Memory (when backend enabled)
| Tool | Purpose |
|---|---|
recall, retain, reflect, memory_edit | Mnemopi/hindsight surfaces |
learn | Autolearn (when autolearn.enabled) |
Other builtins
web_search, github, lsp, ast_edit, checkpoint, rewind, resolve,
set_cwd, manage_skill, launch, inspect_image, argot_load, argot_unload,
generate_image, tts, and MCP tools (mcp__*). Extension hooks may register more.
goal, yield, and report_finding are hidden session-lifecycle tools with no user-facing
spec. The full per-tool specs live under docs/tools/.
Keybindings reference
Quick lookup for the default Veyyon TUI shortcuts. Every row below is taken from the default
binding tables in code (KEYBINDINGS in config/keybindings.ts and TUI_KEYBINDINGS in
@veyyon/tui), so it matches what a fresh profile does. Run /hotkeys in a session for the
live list after your remaps. For the full guide on customizing, see
Keybindings and Vim mode.
App
| Binding | Action |
|---|---|
esc | Interrupt the current operation (app.interrupt) |
ctrl+c | Clear screen or cancel (app.clear) |
ctrl+d | Exit the application (app.exit) |
ctrl+z | Suspend the application (app.suspend) |
ctrl+b | Move the running foreground command to a background job (app.bash.background); active only while a foreground bash call is waiting, otherwise it keeps its editor meaning (cursor left) |
ctrl+l | Reset the terminal display (app.display.reset) |
shift+tab | Cycle thinking level (app.thinking.cycle) |
ctrl+t | Toggle thinking mode (app.thinking.toggle) |
ctrl+p / shift+ctrl+p | Cycle model forward / backward (app.model.cycleForward / app.model.cycleBackward) |
alt+m | Select model (app.model.select) |
alt+p | Select a temporary model for the current session (app.model.selectTemporary) |
ctrl+o | Expand tool output (app.tools.expand) |
ctrl+g | Open the draft in an external editor (app.editor.external) |
ctrl+q or ctrl+enter | Send a follow-up message (app.message.followUp) |
alt+r | Retry the last failed assistant turn (app.retry) |
alt+up | Dequeue a queued message back into the editor (app.message.dequeue) |
alt+shift+p | Toggle plan mode (app.plan.toggle) |
alt+a | Open the subagent dashboard (app.agents.hub) |
ctrl+r | Search prompt history (app.history.search) |
alt+shift+l | Copy the current line (app.clipboard.copyLine) |
alt+shift+c | Copy the whole prompt (app.clipboard.copyPrompt) |
ctrl+v (alt+v fallback on Windows, super+v on macOS) | Paste from the clipboard, image preferred (app.clipboard.pasteImage) |
ctrl+shift+v or alt+shift+v | Paste clipboard text raw, no collapse (app.clipboard.pasteTextRaw) |
You can also set the effort by name with the /effort command (its alias is /thinking). With no
argument it opens a picker; /effort high sets the level directly. The choice lasts for this session; the saved default
lives under Settings → Model → Default Effort.
Unbound by default, remappable: app.session.new, app.session.tree, app.session.fork,
app.session.resume, and app.stt.toggle (speech-to-text; hold Space to record by default).
Composer
| Binding | Action |
|---|---|
enter | Submit the current message (tui.input.submit) |
shift+enter or ctrl+j | Insert a new line (tui.input.newLine) |
tab | Autocomplete (tui.input.tab) |
The composer does not copy. Use alt+shift+l to copy the current line and
alt+shift+c to copy the whole prompt, both listed under Clipboard above.
ctrl+c interrupts the running turn.
Editor
| Binding | Action |
|---|---|
up / down | Move cursor up / down |
left or ctrl+b | Move cursor left |
right or ctrl+f | Move cursor right |
alt+left, ctrl+left, or alt+b | Move cursor left by one word |
alt+right, ctrl+right, or alt+f | Move cursor right by one word |
home or ctrl+a | Move cursor to the start of the line |
end or ctrl+e | Move cursor to the end of the line |
ctrl+] / ctrl+alt+] | Jump forward / backward to a character |
page-up / page-down | Page up / down |
backspace | Delete the character to the left |
delete or ctrl+d | Delete the character to the right |
ctrl+w, alt+backspace, ctrl+backspace, or super+alt+backspace | Delete the word to the left |
alt+delete, alt+d, super+alt+delete, or super+alt+d | Delete the word to the right |
ctrl+u | Delete from the cursor to the start of the line |
ctrl+k | Delete from the cursor to the end of the line |
ctrl+y / alt+y | Yank / yank-pop the kill buffer |
ctrl+- or ctrl+_ | Undo |
Lists and selectors
| Binding | Action |
|---|---|
up / down | Move the selection up / down |
page-up / page-down | Move the selection by one page |
enter | Confirm the selection |
esc or ctrl+c | Cancel and close |
Vim mode
Modal (vim-style) composer editing does not exist. There is no /vim command or
toggle_vim_mode action; the composer uses the bindings above.
Customizing (real path: keybindings.yml)
Custom bindings are shipped, but the config surface is its own file, not a tui.keymap block in
config.yml. Set bindings by action ID in ~/.veyyon/profiles/default/agent/keybindings.yml (YAML map of action ID
→ chord or chord list). A single string, a list of chords, or an empty list (disables the action) are
all valid values:
app.model.cycleForward: Ctrl+P
app.history.search: [] # disables the action
app.clipboard.copyLine: [Ctrl+C, Alt+C]
Action IDs are namespaced (app.model.cycleForward, app.plan.toggle, tui.select.pageUp, …). Older
flat legacy names and keybindings.json files migrate automatically to the namespaced .yml form on
load. Run /hotkeys in a session to see active chords.
Full action-ID list and status-line gestures: Keybindings and Vim mode
and repository docs/handbook/src/reference/keybindings-config.md.
Keybindings
Run /hotkeys inside an veyyon session to see the active chords for your current build. The list reflects any remaps loaded from disk and any bindings added by extensions.
Customize keybindings
User remaps live in ~/.veyyon/profiles/default/agent/keybindings.yml (or the active profile’s agent dir). The file is a YAML mapping whose keys are keybinding action IDs and whose values are either one chord string or an array of chord strings. It is not read from config.yml, and there is no nested keybindings object. Legacy keybindings.json is migrated to keybindings.yml on load.
app.model.cycleForward: Ctrl+P
app.model.selectTemporary: Alt+P
app.plan.toggle: Alt+Shift+P
Chord names are case-insensitive and use the same notation shown in the UI, such as Ctrl+P, Alt+Shift+P, Shift+Enter, and Ctrl+Backspace.
Set an action to an empty array to disable it:
app.history.search: []
Common action IDs
| Action ID | Default | Meaning |
|---|---|---|
app.model.cycleForward | Ctrl+P | Cycle role models forward |
app.model.cycleBackward | Shift+Ctrl+P | Cycle role models backward |
app.model.selectTemporary | Alt+P | Pick a model temporarily for this session |
app.model.select | Alt+M | Open the model selector and set roles |
app.plan.toggle | Alt+Shift+P | Toggle plan mode |
app.history.search | Ctrl+R | Search prompt history |
app.tools.expand | Ctrl+O | Toggle tool-output expansion |
app.thinking.toggle | Ctrl+T | Toggle thinking-block visibility |
app.thinking.cycle | Shift+Tab | Cycle thinking level |
app.editor.external | Ctrl+G | Edit the draft in $VISUAL / $EDITOR |
app.message.followUp | Ctrl+Q, Ctrl+Enter | Queue a follow-up message |
app.message.dequeue | Alt+Up | Dequeue a queued message back into the editor |
app.retry | Alt+R | Retry the last failed assistant turn |
app.display.reset | Ctrl+L | Reset terminal display |
app.bash.background | Ctrl+B | Move the running foreground command to a background job. Active only while a foreground bash call is waiting; the composer’s shortcut band shows a ctrl+b background chip then. Otherwise the chord keeps its readline meaning (cursor left) |
app.clipboard.copyLine | Alt+Shift+L | Copy the current line |
app.clipboard.copyPrompt | Alt+Shift+C | Copy the whole prompt |
app.clipboard.pasteImage | Ctrl+V (Alt+V fallback on Windows, Super+V on macOS) | Paste from the clipboard (image preferred, text fallback) |
app.stt.toggle | Unbound (hold Space) | Toggle speech-to-text. By default there is no key chord, hold the space bar to record (push-to-talk) and release to transcribe; bind a chord here for a press-to-toggle alternative |
On Windows Terminal, Ctrl+V may be handled by the terminal paste command before veyyon sees it; use the Alt+V fallback when clipboard image paste appears to do nothing. When the clipboard holds no image, app.clipboard.pasteImage pastes the clipboard text instead, so hosts that deliver only this chord (VS Code’s integrated terminal when configured to forward Ctrl+V, Windows clipboard history via Win+V) work for both payload kinds. Windows Terminal also swallows Ctrl+Enter, so the app.message.followUp chord also binds Ctrl+Q, the same chord GitHub Copilot CLI uses, and the same chord submits the agent dashboard’s new-agent description and hook-editor prompts. If your existing keybindings.yml already assigns Ctrl+Q to another action, that user remap wins and follow-up keeps Ctrl+Enter unless you explicitly bind app.message.followUp.
Terminals that implement OSC 5522 enhanced paste can send clipboard MIME data directly to veyyon; image pastes are attached as [Image #N], while text/plain paste events keep normal paste behavior. When OSC 5522 is unavailable, bracketed paste still handles text, and a pasted single image-file path is loaded as an image when the file is readable from the veyyon host.
Older unqualified action names are migrated when keybindings.yml is loaded, but new docs and new configs should use the namespaced action IDs above. Existing keybindings.json files are still accepted and migrated to keybindings.yml; keybindings.yaml is also accepted.
Status-line affordances
A few arrow gestures act on the status line when the composer is empty. They are fixed input gestures, not remappable action IDs, so they are not in the table above.
| Gesture | When | Effect |
|---|---|---|
| Down arrow | A goal is active or paused | Open the goal detail menu (same as /goal): objective, tokens against budget, completed turns, time spent, and pause/resume/adjust-budget/drop |
| Double-tap ← (left) | Composer empty | Open the subagent dashboard, or return a focused subagent view to the main session |
Environment variables
The common operator surface: identity and profile selection, provider auth, and the
handful of VEYYON_* variables that are actually read by the runtime today. Veyyon also reads a large
number of VEYYON_* debug/behavior-toggle variables (timing, startup tracing, TUI flags, eval-runtime
toggles, and more) that are less common configuration. For the complete, code-grounded reference,
including every provider credential var, precedence chains, and internal toggles, see
docs/handbook/src/reference/environment-complete.md.
Location and identity
There is no VEYYON_HOME. The config directory name (not a full path) is overridable, and the
active profile is selected by its own variable:
| Variable | Purpose |
|---|---|
VEYYON_CONFIG_DIR | Overrides the config directory name under $HOME (default .veyyon). It is a name, not a path. An absolute value is rejected at startup, with a message stating the directory it would otherwise have created inside your home; to place the config root on another volume, use the XDG_*_HOME variables below. |
VEYYON_CODING_AGENT_DIR | Full override for the agent directory (default ~/<config-dir-name>/profiles/<active-or-default>/agent). |
VEYYON_PROFILE | Selects the active named profile (~/.veyyon/profiles/<name>/agent). |
VEYYON_PACKAGE_DIR | Override package directory for bundled assets (Nix/Guix). |
VEYYON_NO_PTY | Set to 1 to disable PTY-based interactive bash. |
VEYYON_NO_TITLE | Set to disable model-generated session auto-titling (the terminal window title then falls back to the directory name). |
VEYYON_PIPED_STDIN_WAIT_MS | Milliseconds veyyon -p "prompt" waits for the first byte of piped stdin when a prompt is already on the command line (default 10000). Only the wait before the first byte is bounded, so slow producers are still read in full. Set 0 to wait indefinitely. |
VEYYON_WORKTREE_DIR | Absolute path for agent-managed git worktrees (default profile path ~/.veyyon/profiles/<name>/wt; also settable via the worktree.base setting). ~ is expanded; a relative value is ignored. |
VEYYON_GITHUB_CACHE_DB | Full path override for the GitHub view cache database (default ~/.veyyon/profiles/<name>/cache/github-cache.db). |
VEYYON_STREAM_FRAME_MAX_BYTES | Bytes one frame of a streamed protocol may occupy before the reader rejects it: a line, a JSONL record, or an SSE event ending at a blank line. Default 67108864 (64 MiB). Applies to a provider’s response stream, an MCP server’s stdout, and session files. A value that is not a positive integer keeps the default. |
On Linux, veyyon config init-xdg migrates state under $XDG_DATA_HOME/$XDG_STATE_HOME/$XDG_CACHE_HOME
when those are set; unmigrated installs stay under ~/.veyyon. See
packages/utils/src/dirs.ts.
There is no separate SQLite-state-directory override; state lives under the resolved agent directory above.
Authentication
Provider BYOK uses each provider’s native key variable, there is no Veyyon-branded API key or access
token (a VEYYON_API_KEY/VEYYON_ACCESS_TOKEN legacy alias does not exist in the current runtime).
When a provider’s key variable is set, it is used without an interactive sign-in. For providers with
OAuth (Anthropic, xAI, Qwen, Cursor, and others), the OAuth token variable takes precedence over the
plain API key, see the provider tables below and
docs/handbook/src/reference/environment-complete.md.
OAuth sign-in itself is interactive: run /login inside the TUI (or --provider <id> at startup) to
open the OAuth selector. There is no veyyon login --with-api-key/--with-access-token CLI subcommand;
piping a key into a login command is not part of the shipped CLI surface.
Provider keys
Each model provider reads its own standard key variable (or the name in
[model_providers.<id>].env_key for a custom provider). When set, it is used without an interactive
sign-in and wins over a stored key.
| Provider | Variable |
|---|---|
| OpenAI | OPENAI_API_KEY |
| Anthropic | ANTHROPIC_API_KEY (or ANTHROPIC_OAUTH_TOKEN, which takes precedence) |
| DeepSeek | DEEPSEEK_API_KEY |
| Moonshot | MOONSHOT_API_KEY |
| Z.AI | ZAI_API_KEY |
| OpenRouter | OPENROUTER_API_KEY |
| Google Gemini | GEMINI_API_KEY |
| xAI | XAI_API_KEY (or XAI_OAUTH_TOKEN, which takes precedence for xai-oauth) |
| Groq | GROQ_API_KEY |
| Mistral | MISTRAL_API_KEY |
| Cursor | CURSOR_ACCESS_TOKEN |
A custom provider uses whatever variable its [model_providers.<id>].env_key names. See
Configuration and the full provider table in
docs/handbook/src/reference/environment-complete.md
(30+ providers, cloud auth chains for Bedrock/Vertex/Azure, and web-search provider keys).
Local and self-hosted providers
| Variable | Purpose |
|---|---|
OLLAMA_BASE_URL / OLLAMA_HOST | Ollama discovery base URL (defaults to http://127.0.0.1:11434). |
LM_STUDIO_BASE_URL | LM Studio discovery base URL (defaults to http://127.0.0.1:1234/v1). |
LLAMA_CPP_BASE_URL | llama.cpp discovery base URL (defaults to http://127.0.0.1:8080). |
LITELLM_BASE_URL | LiteLLM proxy base URL fallback (defaults to http://localhost:4000/v1). |
VEYYON_EDIT_VARIANT | Force edit tool variant: hashline, apply_patch, patch, replace. |
There is no VEYYON_OSS_BASE_URL/VEYYON_OSS_PORT; each local backend has its own discovery variable
above.
TLS and certificates
| Variable | Purpose |
|---|---|
NODE_EXTRA_CA_CERTS | Extra CA bundle (path or inline PEM) merged into the trust root for every provider fetch (OpenAI-compatible, Codex, Ollama, Azure Responses, Google, Anthropic). |
CLAUDE_CODE_CLIENT_CERT / CLAUDE_CODE_CLIENT_KEY | mTLS client certificate/key, used in Anthropic Foundry gateway mode (CLAUDE_CODE_USE_FOUNDRY=1). |
There is no VEYYON_CA_CERTIFICATE or SSL_CERT_FILE support; NODE_EXTRA_CA_CERTS is the real
override, honored across providers because Bun’s fetch does not read it natively (Veyyon merges it
into RequestInit.tls.ca itself).
Install and updates
| Variable | Purpose |
|---|---|
VEYYON_INSTALL_DIR | Overrides the install script’s target directory (default ~/.local/bin on Unix, %LOCALAPPDATA%\veyyon on Windows). A trailing slash is ignored, so ~/bin and ~/bin/ are the same directory. |
VEYYON_SKIP_SETUP | Skips the first-run setup wizard when set to any value other than empty, 0, false, or no. Use it for unattended or scripted installs. |
There is no VEYYON_NON_INTERACTIVE or VEYYON_INSTALL_URL; the install scripts (scripts/install.sh,
scripts/install.ps1) do not read those names today.
MCP
An MCP server that needs a bearer token takes it as a literal header in mcp.json
(mcpServers.<name>.headers.Authorization) or via /mcp add <name> token <token>, so the secret can live in any
env var you expand yourself (for example plain GITHUB_PERSONAL_ACCESS_TOKEN), not a fixed
VEYYON_* name. There is no VEYYON_GITHUB_PERSONAL_ACCESS_TOKEN or VEYYON_CONNECTORS_TOKEN
convention in the current runtime. A bearer_token_env_var field is honored only when importing
servers from a Codex config.toml, where the token is materialized into an Authorization header at
import time.
| Variable | Purpose |
|---|---|
VEYYON_MCP_TIMEOUT_MS | Overrides the MCP client request timeout (ms) for every server; 0 disables client-side timeouts. Default 30000. |
Remote auth broker (optional)
Real, shipped VEYYON_* variables that switch credential resolution from local SQLite to a remote
broker host:
| Variable | Purpose |
|---|---|
VEYYON_AUTH_BROKER_URL | Base URL of the remote auth-broker; selects broker mode. |
VEYYON_AUTH_BROKER_TOKEN | Bearer token sent to the broker. |
VEYYON_AUTH_BROKER_SNAPSHOT_TTL_MS | Freshness window (ms) for the encrypted local snapshot cache; default 3600000. |
VEYYON_AUTH_BROKER_SNAPSHOT_CACHE | Path to the encrypted local snapshot cache. |
Most installs never set these. Details: docs/internal/auth-broker-gateway.md.
Repair
| Variable | Purpose |
|---|---|
VEYYON_REPAIR_DISABLE | Set to 1, true, or yes to disable the shipped tool-call schema repair (see Repair overview) at the tool-dispatch seam. |
There is no VEYYON_REPAIR_LOG, and Veyyon does not emit per-(model,tool,shape) repair telemetry.
Terminal behavior
| Variable | Purpose |
|---|---|
NO_COLOR | When set (to any value), Veyyon renders without color; hierarchy comes through emphasis, spacing, and glyphs instead. |
TERM / COLORTERM | Read to detect terminal capabilities and choose truecolor, ANSI-256, or ANSI-16 output. TERM=dumb disables every ANSI escape, including emphasis. |
FORCE_COLOR | A non-empty value other than 0 forces full ANSI output, overriding NO_COLOR and TERM=dumb. |
VEYYON_HARDWARE_CURSOR | Truthy enables hardware cursor mode. |
VEYYON_TUI_WRITE_LOG | When set, logs TUI writes to the given file (debugging). |
There is no VEYYON_TUI_DISABLE_KEYBOARD_ENHANCEMENT, VEYYON_TUI_RECORD_SESSION, or
VEYYON_TUI_SESSION_LOG_PATH; see
docs/handbook/src/reference/environment-complete.md
for the real VEYYON_*-prefixed TUI flags.
Removed / does not exist
| Name | Status |
|---|---|
VEYYON_HOME | Never existed. Config location is VEYYON_CONFIG_DIR (dirname override) + optional XDG migration, not a single home-path variable. |
VEYYON_SQLITE_HOME | Never existed. No separate SQLite-state override; state lives under the resolved agent directory. |
VEYYON_API_KEY / VEYYON_ACCESS_TOKEN | Never existed as a Veyyon-branded credential; use each provider’s native key variable. |
VEYYON_AUTHAPI_BASE_URL | Never existed. The ChatGPT AuthAPI host used by personal-access-token whoami is not overridable via env today. |
VEYYON_APP_SERVER_LOGIN_ISSUER | Belonged to the removed app-server daemon; no equivalent exists in this runtime. |
VEYYON_MANAGED_BY_NPM / VEYYON_MANAGED_BY_BUN / VEYYON_MANAGED_PACKAGE_ROOT | Never existed. |
VEYYON_SANDBOX / VEYYON_SANDBOX_NETWORK_DISABLED / VEYYON_THREAD_ID | Never existed under these names. |
Config values can also be overridden per run with one or more --config <file> overlays (repeatable,
never persisted); see the CLI reference.
Environment variables, complete
Every variable the runtime reads, grouped by subsystem. The dozen an operator actually sets are on Environment variables; this page is the exhaustive list, derived from current code paths in:
packages/coding-agent/src/**packages/ai/src/**(provider/auth resolution used by coding-agent)packages/utils/src/**andpackages/tui/src/**where those vars directly affect coding-agent runtime
It documents only active behavior.
Resolution model and precedence
Most runtime lookups use $env from @veyyon/utils (packages/utils/src/env.ts).
$env precedence, high to low:
- Existing process environment (
Bun.env) - Project
.env($PWD/.env) for keys not already set - Agent
.env(~/.veyyon/profiles/default/agent/.env, respectingVEYYON_CONFIG_DIR/VEYYON_CODING_AGENT_DIR) for keys not already set - Config-root
.env(~/.veyyon/profiles/default/.env, respectingVEYYON_CONFIG_DIRand the active profile) for keys not already set - Home
.env(~/.env) for keys not already set
Precedence is not the same as load order, and the difference matters if you are reading the source. Part of the home .env is applied FIRST, by packages/utils/src/dotenv-home.ts, because a VEYYON_CODING_AGENT_DIR or XDG_CONFIG_HOME set there sets where layers 3 and 4 are. packages/utils/src/dirs.ts imports that module before it resolves anything, so a directory is never computed from a pre-.env environment. env.ts then applies all four layers in full, overriding the values home contributed and nothing else, and refreshes the directory resolver. The result is the precedence listed above, whichever module a program imported.
That early phase applies only the variables that decide where a directory is: VEYYON_CODING_AGENT_DIR, VEYYON_CONFIG_DIR, and the four XDG_* bases. Everything else in your home .env, including every API key, waits for env.ts. The reason is that whatever is in the environment that early is inherited by every process veyyon spawns, and the sandboxed evaluator that runs your eval code is one of them: it should not receive your credentials.
Two variables are deliberately not read from a .env file at all. VEYYON_PROFILE selects the profile, and the profile sets where layers 3 and 4 are, so reading it out of one of them would be circular; set it in your shell or pass --profile. PATH is read only after env.ts has run, which is when binary lookup happens anyway, so extending PATH in a .env works as it always has.
1) Model/provider authentication
These are consumed via getEnvApiKey() (packages/ai/src/env-api-key.ts) unless noted otherwise.
Core provider credentials
The provider-first projection of this map lives in Providers; the two tables are two views of one source.
| Variable | Used for | Required when | Notes / precedence |
|---|---|---|---|
OPENAI_CODEX_OAUTH_TOKEN | OpenAI Codex OAuth auth | Using openai-codex provider | |
BASETEN_API_KEY | Baseten auth | Using baseten provider | |
CMD_API_KEY | Command Code auth | Using command-code provider | Preferred key alias; endpoint https://api.commandcode.ai/provider/v1, default moonshotai/Kimi-K2.7-Code |
COMMAND_CODE_API_KEY | Command Code auth | Using command-code provider without CMD_API_KEY | Fallback after CMD_API_KEY; keys are issued at https://commandcode.ai/studio/provider |
NOUS_API_KEY | Nous Research explicit/headless auth | Using nous-research without a /login | Either login is preferred: nous-research (device flow) or nous-research-api-key (pasted key); endpoint https://inference-api.nousresearch.com/v1, default anthropic/claude-sonnet-4.6 |
COREWEAVE_API_KEY | CoreWeave auth | Using coreweave provider | Takes precedence over WANDB_API_KEY |
WANDB_API_KEY | CoreWeave auth | Using coreweave provider | Fallback after COREWEAVE_API_KEY |
DEVIN_API_KEY | Devin auth | Using devin provider | |
SAKANA_API_KEY | Sakana auth | Using sakana provider | Takes precedence over FUGU_API_KEY |
FUGU_API_KEY | Sakana auth | Using sakana provider | Fallback after SAKANA_API_KEY |
ANTHROPIC_OAUTH_TOKEN | Anthropic API auth | Using Anthropic with OAuth token auth | Takes precedence over ANTHROPIC_API_KEY for provider auth resolution |
ANTHROPIC_API_KEY | Anthropic API auth | Using Anthropic without OAuth token | Fallback after ANTHROPIC_OAUTH_TOKEN |
ANTHROPIC_FOUNDRY_API_KEY | Anthropic via Azure Foundry / enterprise gateway | CLAUDE_CODE_USE_FOUNDRY enabled | Takes precedence over ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY when Foundry mode is enabled |
OPENAI_API_KEY | OpenAI auth | Using OpenAI-family providers without explicit apiKey argument | Used by OpenAI Completions/Responses providers |
GEMINI_API_KEY | Google Gemini auth | Using google provider models | Primary key for Gemini provider mapping |
GOOGLE_API_KEY | Gemini image tool auth fallback | Using gemini_image tool without GEMINI_API_KEY | Used by coding-agent image tool fallback path |
GROQ_API_KEY | Groq auth | Using Groq models | |
CEREBRAS_API_KEY | Cerebras auth | Using Cerebras models | |
FIREWORKS_API_KEY | Fireworks auth | Using Fireworks models | |
FIREPASS_API_KEY | Fire Pass auth | Using Fire Pass models | |
TOGETHER_API_KEY | Together auth | Using together provider | |
AIMLAPI_API_KEY | AIML API auth | Using aimlapi provider | OpenAI-compatible AIML API endpoint at https://api.aimlapi.com/v1 |
HUGGINGFACE_HUB_TOKEN | Hugging Face auth | Using huggingface provider | Primary Hugging Face token env var |
HF_TOKEN | Hugging Face auth | Using huggingface provider | Fallback when HUGGINGFACE_HUB_TOKEN is unset |
SYNTHETIC_API_KEY | Synthetic auth | Using Synthetic models | |
NVIDIA_API_KEY | NVIDIA auth | Using nvidia provider | |
NANO_GPT_API_KEY | NanoGPT auth | Using nanogpt provider | |
NOVITA_API_KEY | Novita auth | Using novita provider | |
VENICE_API_KEY | Venice auth | Using venice provider | |
LITELLM_API_KEY | LiteLLM auth | Using litellm provider | OpenAI-compatible LiteLLM proxy key |
LM_STUDIO_API_KEY | LM Studio auth (optional) | Using lm-studio provider with authenticated hosts | Local LM Studio usually runs without auth; any non-empty token works when a key is required |
OLLAMA_API_KEY | Ollama auth (optional) | Using ollama provider with authenticated hosts | Local Ollama usually runs without auth; any non-empty token works when a key is required |
LLAMA_CPP_API_KEY | llama.cpp auth (optional) | Using llama.cpp provider with authenticated hosts | Local llama.cpp usually runs without auth; any non-empty token works when a key is configured |
XIAOMI_API_KEY | Xiaomi MiMo auth | Using xiaomi provider | |
XIAOMI_TOKEN_PLAN_AMS_API_KEY | Xiaomi MiMo Token Plan auth (AMS) | Using xiaomi-token-plan-ams provider | |
XIAOMI_TOKEN_PLAN_CN_API_KEY | Xiaomi MiMo Token Plan auth (CN) | Using xiaomi-token-plan-cn provider | |
XIAOMI_TOKEN_PLAN_SGP_API_KEY | Xiaomi MiMo Token Plan auth (SGP) | Using xiaomi-token-plan-sgp provider | |
MOONSHOT_API_KEY | Moonshot auth | Using moonshot provider | |
XAI_API_KEY | xAI auth | Using xAI models or as fallback for xai-oauth | |
XAI_OAUTH_TOKEN | xAI OAuth/SuperGrok auth | Using xai-oauth provider | Takes precedence over XAI_API_KEY for xai-oauth |
OPENROUTER_API_KEY | OpenRouter auth | Using OpenRouter models | Also used by image tool when preferred/auto provider is OpenRouter |
MISTRAL_API_KEY | Mistral auth | Using Mistral models | |
ZAI_API_KEY | z.ai auth | Using z.ai models | Also used by z.ai web search provider |
ZHIPU_API_KEY | Zhipu Coding Plan auth | Using zhipu-coding-plan provider | |
UMANS_AI_CODING_PLAN_API_KEY | Umans AI Coding Plan auth | Using umans provider | |
MINIMAX_API_KEY | MiniMax auth | Using minimax provider | |
MINIMAX_CODE_API_KEY | MiniMax Code auth | Using minimax-code provider | |
MINIMAX_CODE_CN_API_KEY | MiniMax Code CN auth | Using minimax-code-cn provider | |
OPENCODE_API_KEY | OpenCode auth | Using opencode-go / opencode-zen models | |
QIANFAN_API_KEY | Qianfan auth | Using qianfan provider | |
QWEN_OAUTH_TOKEN | Qwen Portal auth | Using qwen-portal with OAuth token | Takes precedence over QWEN_PORTAL_API_KEY |
QWEN_PORTAL_API_KEY | Qwen Portal auth | Using qwen-portal with API key | Fallback after QWEN_OAUTH_TOKEN |
ZENMUX_API_KEY | ZenMux auth | Using zenmux provider | Used for ZenMux OpenAI and Anthropic-compatible routes |
VLLM_API_KEY | vLLM auth/discovery opt-in | Using vllm provider (local OpenAI-compatible servers) | Any non-empty value works for no-auth local servers |
CURSOR_ACCESS_TOKEN | Cursor provider auth | Using Cursor provider | |
AI_GATEWAY_API_KEY | Vercel AI Gateway auth | Using vercel-ai-gateway provider | |
CLOUDFLARE_AI_GATEWAY_API_KEY | Cloudflare AI Gateway auth | Using cloudflare-ai-gateway provider | Base URL must be configured as https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic |
ALIBABA_CODING_PLAN_API_KEY | Alibaba Coding Plan auth | Using alibaba-coding-plan provider | |
DEEPSEEK_API_KEY | DeepSeek auth | Using DeepSeek models | |
KILO_API_KEY | Kilo auth | Using Kilo models | |
OLLAMA_CLOUD_API_KEY | Ollama Cloud auth | Using ollama-cloud provider | |
WAFER_SERVERLESS_API_KEY | Wafer Serverless auth | Using wafer-serverless provider | Pay-as-you-go Wafer SKU; validated against https://pass.wafer.ai/v1/models |
GITLAB_TOKEN | GitLab Duo auth | Using gitlab-duo provider |
/login nous-research stores the Portal refresh token and supplies refreshed short-lived inference access tokens to requests and model discovery. When no stored OAuth credential is selected, NOUS_API_KEY supplies the headless fallback.
GitHub/Copilot tokens
| Variable | Used for | Notes |
|---|---|---|
COPILOT_GITHUB_TOKEN | GitHub Copilot provider auth | Generic GitHub tokens are not used here |
GH_TOKEN | GitHub API auth in web scraper | Web scraper fallback after GITHUB_TOKEN |
GITHUB_TOKEN | GitHub API auth in web scraper | Web scraper checks this before GH_TOKEN |
Auth broker / auth gateway (remote credential vault)
When the broker is enabled, the local SQLite credential store is bypassed and all OAuth refresh / access tokens live on the broker host. See auth-broker-gateway.md for the full protocol, CLI surface, and 5-min/15-s usage cache layering.
| Variable | Used for | Required when | Notes / precedence |
|---|---|---|---|
VEYYON_AUTH_BROKER_URL | Base URL of the remote auth-broker (e.g. https://broker.tailnet:8765); selects broker mode | Resolving credentials through a broker; also required by veyyon auth-gateway serve (the gateway is itself a broker client) | Wins over auth.broker.url in config.yml. When set with no resolvable token, resolveAuthBrokerConfig() hard-errors instead of falling back to local SQLite. |
VEYYON_AUTH_BROKER_TOKEN | Bearer token sent on every broker endpoint except /v1/healthz | VEYYON_AUTH_BROKER_URL is set and no token is available from auth.broker.token or <config-dir>/auth-broker.token | Resolution: this env → auth.broker.token ($ENV_NAME indirection supported) → <config-dir>/auth-broker.token (mode 0600). <config-dir> is ~/.veyyon/ (respecting VEYYON_CONFIG_DIR). |
VEYYON_AUTH_BROKER_SNAPSHOT_TTL_MS | Freshness window for the encrypted local broker snapshot cache | Optional in broker mode | Default 3600000 (1 h). Freshness is based on broker snapshot.generatedAt; 0 disables cache reads/writes and forces the old blocking fetch every startup. |
VEYYON_AUTH_BROKER_SNAPSHOT_CACHE | Path to the encrypted local broker snapshot cache | Optional in broker mode | Defaults to ~/.veyyon/profiles/<profile>/cache/auth-broker-snapshot.enc (or XDG cache equivalent). Useful for tests, ephemeral hosts, or relocating the 0600 cache file. |
The gateway has no dedicated env vars, it inherits VEYYON_AUTH_BROKER_*. Its own inbound bearer token lives at <config-dir>/auth-gateway.token and is managed via veyyon auth-gateway token.
2) Provider-specific runtime configuration
Anthropic Foundry Gateway (Azure / enterprise proxy)
When CLAUDE_CODE_USE_FOUNDRY is enabled, Anthropic requests switch to Foundry mode:
-
Base URL resolves from
FOUNDRY_BASE_URL(fallback remains model/default base URL if unset). -
API key resolution for provider
anthropicbecomes:ANTHROPIC_FOUNDRY_API_KEY→ANTHROPIC_OAUTH_TOKEN→ANTHROPIC_API_KEY. -
ANTHROPIC_CUSTOM_HEADERSis parsed as comma/newline-separatedkey: valuepairs and merged into request headers. They are also forwarded whenANTHROPIC_BASE_URLpoints to a non-Anthropic host (e.g. a corporate API gateway), so enterprise gateways requiring proprietary auth headers work without enabling Foundry mode. -
TLS client/server material can be injected from env values:
NODE_EXTRA_CA_CERTS,CLAUDE_CODE_CLIENT_CERT,CLAUDE_CODE_CLIENT_KEY. Each accepts either:- a filesystem path to PEM content, or
- inline PEM (including escaped
\nsequences).
NODE_EXTRA_CA_CERTSis honoured for every provider fetch (OpenAI-compatible, Codex, Ollama, Azure Responses, Google, Anthropic), not just Foundry, Bun’sfetchdoes not consume the env var natively, so the bundle is merged intoRequestInit.tls.caalongside the system root store. TheCLAUDE_CODE_*mTLS material remains Anthropic-Foundry-specific.
| Variable | Value type | Behavior |
|---|---|---|
CLAUDE_CODE_USE_FOUNDRY | Boolean-like string (1, true, yes, on) | Enables Foundry mode for Anthropic provider |
FOUNDRY_BASE_URL | URL string | Anthropic endpoint base URL in Foundry mode |
ANTHROPIC_FOUNDRY_API_KEY | Token string | Used for Authorization: Bearer <token> |
ANTHROPIC_CUSTOM_HEADERS | Header list string | Extra headers; format header-a: value, header-b: value or newline-separated. Also forwarded outside Foundry whenever ANTHROPIC_BASE_URL is non-Anthropic. |
NODE_EXTRA_CA_CERTS | PEM path or inline PEM | Extra CA chain for server certificate validation |
CLAUDE_CODE_CLIENT_CERT | PEM path or inline PEM | mTLS client certificate |
CLAUDE_CODE_CLIENT_KEY | PEM path or inline PEM | mTLS client private key (must be paired with cert) |
Amazon Bedrock
| Variable | Default / behavior |
|---|---|
AWS_REGION | Primary region source |
AWS_DEFAULT_REGION | Fallback if AWS_REGION unset |
AWS_PROFILE | Enables named profile auth path |
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY | Enables IAM key auth path |
AWS_BEARER_TOKEN_BEDROCK | Highest-precedence bearer token auth path; skips AWS profile/credential-chain lookup when set |
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI / AWS_CONTAINER_CREDENTIALS_FULL_URI | Marks Bedrock as available in provider detection (credential resolution itself covers env keys, profiles/SSO/credential_process, then IMDSv2) |
AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN | Marks Bedrock as available in provider detection (same caveat as the ECS variables above) |
AWS_BEDROCK_SKIP_AUTH | If 1, injects dummy credentials (proxy/non-auth scenarios) |
HTTPS_PROXY / HTTP_PROXY | Honored via Bun’s native fetch proxy support (the provider no longer ships an AWS SDK / proxy-agent transport) |
NO_PROXY | Excludes matching hosts from Bun’s native proxy routing |
Region fallback in provider code: options.region → AWS_REGION → AWS_DEFAULT_REGION → us-east-1.
Azure OpenAI Responses
| Variable | Default / behavior |
|---|---|
AZURE_OPENAI_API_KEY | Required unless API key passed as option |
AZURE_OPENAI_API_VERSION | Default v1 |
AZURE_OPENAI_BASE_URL | Direct base URL override |
AZURE_OPENAI_RESOURCE_NAME | Used to construct base URL: https://<resource>.openai.azure.com/openai/v1 |
AZURE_OPENAI_DEPLOYMENT_NAME_MAP | Optional mapping string: modelId=deploymentName,model2=deployment2 |
Base URL resolution: option azureBaseUrl → env AZURE_OPENAI_BASE_URL → option/env resource name → the model row’s baseUrl in models.yml.
Google Vertex AI
| Variable | Required? | Notes |
|---|---|---|
GOOGLE_CLOUD_PROJECT | Yes (unless passed in options) | Primary project ID source |
GCP_PROJECT | Fallback | Alternate project ID source |
GCLOUD_PROJECT | Fallback | Alternate project ID source |
GOOGLE_CLOUD_PROJECT_ID | OAuth login helper only | Used by Gemini CLI OAuth project discovery |
GOOGLE_VERTEX_LOCATION | Yes (unless passed in options) | Primary Vertex location source |
GOOGLE_CLOUD_LOCATION | Fallback | Alternate Vertex location source |
VERTEX_LOCATION | Fallback | Alternate Vertex location source |
GOOGLE_CLOUD_API_KEY | Conditional | Direct Vertex API-key auth; otherwise ADC fallback can authenticate when project and location are set |
GOOGLE_APPLICATION_CREDENTIALS | Conditional | If set, file must exist; otherwise ADC fallback path is checked (~/.config/gcloud/application_default_credentials.json) |
Kimi
| Variable | Default / behavior |
|---|---|
KIMI_CODE_OAUTH_HOST | Primary OAuth host override |
KIMI_OAUTH_HOST | Fallback OAuth host override |
KIMI_CODE_BASE_URL | Overrides Kimi usage endpoint base URL (usage/kimi.ts) |
OAuth host chain: KIMI_CODE_OAUTH_HOST → KIMI_OAUTH_HOST → https://auth.kimi.com.
Gemini CLI compatibility
| Variable | Default / behavior |
|---|---|
VEYYON_AI_GEMINI_CLI_VERSION | Overrides the Gemini CLI user-agent version tag (0.46.0 if unset) |
VEYYON_AI_ANTIGRAVITY_VERSION | Overrides the Antigravity / Cloud Code Assist user-agent version tag (2.1.4 if unset) |
OpenAI Codex responses (feature/debug controls)
| Variable | Behavior |
|---|---|
VEYYON_CODEX_DEBUG | 1/true enables Codex provider debug logging |
VEYYON_CODEX_WEBSOCKET | 1/true enables websocket transport preference |
VEYYON_OPENAI_STATEFUL | Overrides the stateful-chaining default for the platform OpenAI Responses API (previous_response_id, forces store: true): on by default against api.openai.com, off elsewhere |
VEYYON_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS | Positive integer override (default 300000) |
VEYYON_CODEX_WEBSOCKET_RETRY_BUDGET | Non-negative integer override (default 5) |
VEYYON_CODEX_WEBSOCKET_RETRY_DELAY_MS | Positive integer base backoff override (default 500) |
VEYYON_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS | Positive integer wait for the first websocket event before falling back to SSE (default 60000). Switches transport rather than failing the request |
VEYYON_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS | Longest quiet period a reused socket is trusted for before a fresh handshake (default 30000); 0 disables the ceiling |
VEYYON_CODEX_WEBSOCKET_PING_INTERVAL_MS | Positive integer keepalive ping interval (default 10000) |
VEYYON_CODEX_WEBSOCKET_PONG_TIMEOUT_MS | Positive integer wait for a pong before the socket is treated as dead (default 60000) |
VEYYON_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY | Positive integer inbound frame queue capacity (default 4096) |
VEYYON_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS | Positive integer OpenAI first-event timeout override; 0 disables. veyyon config set providers.streamFirstEventTimeoutSeconds <seconds> provides the persisted config equivalent |
VEYYON_OPENAI_STREAM_IDLE_TIMEOUT_MS | Positive integer OpenAI stream idle timeout override; 0 disables. veyyon config set providers.streamIdleTimeoutSeconds <seconds> provides the persisted config equivalent |
Cursor provider debug
| Variable | Behavior |
|---|---|
DEBUG_CURSOR | Enables provider debug logs; 2/verbose for detailed payload snippets |
DEBUG_CURSOR_LOG | Optional file path for JSONL debug log output |
Transport selection (OpenRouter, Perplexity)
Both providers can be reached over two different HTTP APIs, and these pick which one.
| Variable | Behavior |
|---|---|
VEYYON_OPENROUTER_RESPONSES | OpenRouter uses the Responses API unless this is exactly 0, which selects chat completions. Any other value leaves the default in place |
VEYYON_PERPLEXITY_RESPONSES | Perplexity web search uses the Responses API only when this is exactly 1 |
Prompt cache controls
| Variable | Behavior |
|---|---|
VEYYON_CACHE_RETENTION | If long, enables long retention where supported (anthropic, openai-responses, Bedrock retention resolution) |
VEYYON_CACHE_ENFORCEMENT | What happens when a request’s cache markers demonstrably did not take effect: off, warn (default), or error. error aborts the turn. Anthropic only. Overridden by an explicit per-request level, and the cache.blockOnRejection setting selects error through the same resolver. See prompt caching. |
3) Web search subsystem
Search provider credentials
| Variable | Used by |
|---|---|
EXA_API_KEY | Exa search provider and Exa MCP tools |
BRAVE_API_KEY | Brave search provider |
PERPLEXITY_API_KEY | Perplexity search provider API-key mode |
PERPLEXITY_COOKIES | Perplexity cookie-auth search mode |
TAVILY_API_KEY | Tavily search provider |
ZAI_API_KEY | z.ai search provider (also checks stored OAuth in agent.db) |
OPENAI_API_KEY / Codex OAuth in DB | Codex search provider availability/auth |
VEYYON_CODEX_WEB_SEARCH_MODEL | Codex search provider model override |
MOONSHOT_SEARCH_API_KEY / KIMI_SEARCH_API_KEY | Kimi/Moonshot search provider env auth |
MOONSHOT_SEARCH_BASE_URL / KIMI_SEARCH_BASE_URL | Kimi/Moonshot search endpoint override |
KAGI_API_KEY | Kagi search provider |
JINA_API_KEY | Jina search provider |
PARALLEL_API_KEY | Parallel search provider |
SEARXNG_ENDPOINT, SEARXNG_TOKEN | SearXNG endpoint and optional bearer token |
SEARXNG_BASIC_USERNAME, SEARXNG_BASIC_PASSWORD | SearXNG HTTP Basic Auth credentials |
SearXNG also reads the equivalent searxng.endpoint, searxng.token, searxng.basicUsername, and searxng.basicPassword settings from ~/.veyyon/profiles/default/agent/config.yml; environment variables are fallbacks.
Anthropic web search auth chain
searchAnthropic() resolves credentials in this order:
ANTHROPIC_SEARCH_API_KEYauthStorage.getApiKey("anthropic")fallback credentials (runtime/config overrides, stored API-key credentials, stored OAuth credentials, then generic Anthropic env fallback:ANTHROPIC_FOUNDRY_API_KEYin Foundry mode, otherwiseANTHROPIC_OAUTH_TOKEN/ANTHROPIC_API_KEY)
For either credential path, base URL resolution is:
ANTHROPIC_SEARCH_BASE_URLFOUNDRY_BASE_URLwhenCLAUDE_CODE_USE_FOUNDRYis enabledANTHROPIC_BASE_URLhttps://api.anthropic.com
Related vars:
| Variable | Default / behavior |
|---|---|
ANTHROPIC_SEARCH_API_KEY | API key used exclusively for the Anthropic web search provider. Highest-priority search auth; overrides ANTHROPIC_API_KEY / OAuth / Foundry for search calls without affecting chat completions. |
ANTHROPIC_SEARCH_BASE_URL | Base URL used exclusively for the Anthropic web search provider. Applied to either ANTHROPIC_SEARCH_API_KEY or fallback Anthropic credentials; overrides ANTHROPIC_BASE_URL (and FOUNDRY_BASE_URL in Foundry mode) for search calls. |
ANTHROPIC_SEARCH_MODEL | Search model override. Defaults to claude-haiku-4-5. |
ANTHROPIC_BASE_URL | Generic fallback base URL for Anthropic requests when no search-specific base URL is set. |
Use ANTHROPIC_SEARCH_BASE_URL (optionally with ANTHROPIC_SEARCH_API_KEY) to keep chat routed through an enterprise gateway (ANTHROPIC_BASE_URL or CLAUDE_CODE_USE_FOUNDRY=true) while pointing web search at a direct Anthropic endpoint, or vice versa.
Perplexity OAuth flow behavior flag
| Variable | Behavior |
|---|---|
VEYYON_AUTH_NO_BORROW | If set, disables macOS native-app token borrowing path in Perplexity login flow |
4) Python tooling and kernel runtime
| Variable | Default / behavior |
|---|---|
VEYYON_PY | Boolean-like override for the Python eval backend: truthy (1/true/yes/on) enables, any other value disables; unset defers to the eval.py setting (default enabled) |
VEYYON_JS | Same boolean-like override for the JavaScript eval backend; unset defers to the eval.js setting (default enabled) |
VEYYON_PYTHON_SKIP_CHECK | If 1, skips Python interpreter availability checks (subprocess runner still starts on demand) |
VEYYON_PYTHON_INTEGRATION | If 1, opts gated integration tests in (e.g. python-runner-integration.test.ts) into running against real Python |
VEYYON_PYTHON_IPC_TRACE | If 1, logs NDJSON frames exchanged with the Python runner subprocess |
VEYYON_RUBY_IPC_TRACE | Same, for the Ruby runner subprocess |
VEYYON_JULIA_IPC_TRACE | Same, for the Julia runner subprocess |
VIRTUAL_ENV | Highest-priority venv path for Python runtime resolution |
Extra conditional behavior:
- If
BUN_ENV=testorNODE_ENV=test, Python availability checks are treated as OK and warming is skipped. - Python env filtering denies common API keys and allows safe base vars +
LC_,XDG_,VEYYON_prefixes. - Every eval kernel spells its IPC trace variable
VEYYON_<LANGUAGE>_IPC_TRACE, with the language spelled out in full (PYTHON,RUBY,JULIA), not abbreviated the way the source directories are. A language added later follows the same convention: it comes from one helper inpackages/coding-agent/src/eval/kernel-base.tsrather than from each kernel formatting its own name.
5) Agent/runtime behavior toggles
| Variable | Default / behavior |
|---|---|
VEYYON_SMOL_MODEL | Ephemeral model-role override for smol (CLI --smol takes precedence) |
VEYYON_STREAM_FRAME_MAX_BYTES | Bytes one frame of a streamed protocol may occupy before the reader rejects it and cancels the source: a line, a JSONL record, or an SSE event ending at a blank line. Default 67108864 (64 MiB). Covers provider response streams, MCP server stdout and session files. A value that is not a positive integer keeps the default, so a typo cannot remove the bound. The refusal is terminal and never retried. |
VEYYON_SLOW_MODEL | Ephemeral model-role override for slow (CLI --slow takes precedence) |
VEYYON_PLAN_MODEL | Ephemeral model-role override for plan (CLI --plan takes precedence) |
VEYYON_NO_TITLE | If set (any non-empty value), disables auto session title generation on first user message |
VEYYON_SKIP_SETUP | If set to any value other than empty/0/false/no (case-insensitive), skips the first-run setup wizard (no setup scenes are shown). |
VEYYON_TINY_DEVICE | ONNX execution provider for local tiny models; overrides the providers.tinyModelDevice setting (setting default: default, which selects CPU-only inference; also supports cpu, gpu, metal/webgpu, auto, cuda, dml, coreml, wasm, webnn, webnn-gpu, webnn-cpu, webnn-npu) |
VEYYON_TINY_DTYPE | ONNX quantization/precision for local tiny models; overrides the providers.tinyModelDtype setting (setting default: default, which selects each model’s shipped dtype, currently q4; also supports auto, fp32, fp16, q8, int8, uint8, q4, bnb4, q4f16, q2, q2f16, q1, q1f16) |
VEYYON_NO_INTERLEAVED_THINKING | If 1, disables Anthropic interleaved thinking budget behavior and uses output-token inflation for older thinking mode |
VEYYON_NO_INTENT | If 1, tool schemas ship without the injected i intent parameter, so the model stops stating what each call is for. Tools that already opt out are unaffected |
NULL_PROMPT | If true, system prompt builder returns empty string |
VEYYON_BLOCKED_AGENT | Self-recursion prevention: read once from the process env when the task tool is constructed, and any spawn whose agent type equals it is rejected with Cannot spawn <name> agent from within itself (recursion prevention). The comparison is against the name the caller wrote, before retired-name resolution, so it must be spelled the way the spawn spells it (deep, not the retired task, unless the caller itself writes task). Nothing in the product sets it; it is a harness/debug knob. |
VEYYON_SUBPROCESS_CMD | Overrides subagent spawn command (veyyon / veyyon.cmd resolution bypass) |
VEYYON_TASK_MAX_OUTPUT_BYTES | Max captured output bytes per subagent (default 500000) |
VEYYON_TASK_MAX_OUTPUT_LINES | Max captured output lines per subagent (default 5000) |
VEYYON_TIMING | If set (any non-empty value), prints a hierarchical timing-span tree to stderr via logger.printTimings(). In interactive mode the tree prints once the agent is ready (before the TUI starts); in print mode it prints after the whole prompt batch completes. Print-mode prompts are wrapped in print:prompt:initial / print:prompt:next spans so each user message shows up as its own row. VEYYON_TIMING=x exits the process with code 0 right after printing in interactive mode (use to measure cold startup only). VEYYON_TIMING=full lists every module-load entry instead of just the top N. |
VEYYON_DEBUG_STARTUP | If set (any non-empty value), streams one synchronous [startup] <phase>:start / :done marker line to stderr as each startup phase begins/ends, including command-module imports (cli:load:<name>) and the native addon extraction/dlopen (native:*). Unlike VEYYON_TIMING (which prints only once startup completes), the markers survive a hard hang: the last line on stderr states the phase the process is stuck in. Combine with VEYYON_TIMING freely; markers and the span tree share the same phase names. |
VEYYON_PACKAGE_DIR | Overrides package asset base dir resolution (docs, examples, and CHANGELOG assets) |
VEYYON_REPAIR_DISABLE | If 1/true/yes, disables malformed-tool-call schema repair (calls fail instead of being repaired) |
VEYYON_DISABLE_LSPMUX | If 1, disables lspmux detection/integration and forces direct LSP server spawning |
VEYYON_RPC_EMIT_TITLE | Boolean-like flag enabling title events in RPC mode |
SMITHERY_URL | Smithery web URL override (default https://smithery.ai) |
SMITHERY_API_URL | Smithery API base URL override (default https://api.smithery.ai) |
SMITHERY_API_KEY | Smithery API key for managed MCP auth lookup |
PUPPETEER_EXECUTABLE_PATH | Browser tool Chromium executable override |
LITELLM_BASE_URL | LiteLLM proxy base URL fallback (http://localhost:4000/v1 if unset); an explicit baseUrl on the litellm provider in models.yml wins |
LM_STUDIO_BASE_URL | Default implicit LM Studio discovery base URL override (http://127.0.0.1:1234/v1 if unset) |
OLLAMA_BASE_URL | Default implicit Ollama discovery base URL override (OLLAMA_HOST if unset, then http://127.0.0.1:11434) |
OLLAMA_HOST | Ollama host used for implicit Ollama discovery when OLLAMA_BASE_URL is unset; accepts Ollama-style values such as 127.0.0.1:11434 or http://host:11434 |
OLLAMA_CONTEXT_LENGTH | Positive integer context-window override for implicit Ollama discovery; affects Veyyon context budgeting only and does not change Ollama’s runtime num_ctx |
LLAMA_CPP_BASE_URL | Default implicit Llama.cpp discovery base URL override (http://127.0.0.1:8080 if unset) |
VEYYON_EDIT_VARIANT | Forces edit tool variant when valid (patch, replace, hashline, apply_patch) |
VEYYON_STRICT_EDIT_MODE | If 1, suppresses the built-in per-model fallback that turns a hashline default into another mode for models known to do badly with it (today: any model id containing kimi falls back to replace). It does not affect the two overrides that outrank edit.mode anyway: an edit.modelVariants pattern that matches the active model wins first, then VEYYON_EDIT_VARIANT, then edit.mode |
VEYYON_FORCE_IMAGE_PROTOCOL | Forces supported image protocol (kitty, iterm2/iterm, sixel, none) where used |
VEYYON_ALLOW_SIXEL_PASSTHROUGH | Allows SIXEL passthrough when VEYYON_FORCE_IMAGE_PROTOCOL=sixel |
VEYYON_NO_WEBP | If 1 or true (case-insensitive), excludes WebP from image encoding so images are sent as PNG/JPEG only (llama.cpp/Ollama STB decoders cannot read WebP). Read per call, so it takes effect at runtime. |
VEYYON_NO_PTY | If 1, disables interactive PTY path for bash tool |
VEYYON_DIALECT | Force-enables owned (in-band) tool calling with the named dialect when no configured dialect wins: glm, hermes, kimi, xml, anthropic, deepseek, harmony, qwen3, gemini, gemma, minimax, pi-native; 1/true mean glm. Unrecognized values are ignored. Same set as the tools.format setting. |
VEYYON_HARMONY_DEBUG | Debug. If 1, includes the full removed text as removedBlob in each harmony-leak audit event (the onHarmonyLeak hook); otherwise only a redacted length/hash/preview is emitted. Use to inspect what harmony-leak scrubbing removed. |
VEYYON_MCP_TIMEOUT_MS | Overrides MCP client request timeout (ms) for every MCP server. 0 disables client-side timeouts (AbortSignal never fires). Invalid (negative or non-numeric) values are ignored with a warning and the per-server config or default (30000) is used |
VEYYON_PIPED_STDIN_WAIT_MS | How long veyyon -p "prompt" waits for the FIRST byte of piped stdin when the prompt is already on the command line (default 10000). It bounds only the wait before anything arrives, so a slow or large piped document is still read in full; 0 waits indefinitely, which is what happens when the pipe is your only input. It exists because a parent process that spawns Veyyon with an inherited pipe it never writes to and never closes sends no EOF, and the run would otherwise block forever. |
VEYYON_STACK | If 1, fatal CLI errors print the full inspected error (stack + source context) instead of the default concise message + cause chain report |
VEYYON_EDIT_FUZZY | true/1 or false/0 forces fuzzy matching in the edit tool on or off; auto (the default) reads the edit.fuzzyMatch setting. Any other value fails the call |
VEYYON_EDIT_FUZZY_THRESHOLD | Similarity floor for fuzzy matching, 0-1. auto (the default) reads the edit.fuzzyThreshold setting. A value outside the range, or not a number, fails the call |
VEYYON_STREAM_FIRST_EVENT_TIMEOUT_MS | Cross-provider first-event watchdog in ms; 0 disables it. A per-request streamFirstEventTimeoutMs wins, and OpenAI-family transports prefer VEYYON_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS. Default 100000 |
VEYYON_STREAM_IDLE_TIMEOUT_MS | Cross-provider maximum idle gap between streamed events in ms, applied once the first event has arrived; 0 disables it. A per-request streamIdleTimeoutMs wins, and VEYYON_OPENAI_STREAM_IDLE_TIMEOUT_MS is the OpenAI-family alias. Default 120000 |
VEYYON_NO_THINKING_LOOP_GUARD | If 1, disables the repeated-thinking-block loop detector for the models it normally guards |
VEYYON_MAX_AST_FILES | Positive integer cap on how many files one ast_edit call may rewrite (default 1000) |
VEYYON_TOKENIZER_ACCURATE | If 1, uses the accurate tokenizer instead of the fast estimate. Ignored under NODE_ENV=test |
VEYYON_REQ_DEBUG | If 1, dumps every provider HTTP request and its response stream to rr-session-<n>.json and rr-session-<n>.res.log in the process working directory, mode 0600. The dumps include request headers, so they contain credentials. A dump that cannot be written is logged and the request proceeds |
VEYYON_PROXY | Egress proxy URL for every provider request. VEYYON_PROXY_<PROVIDER> (the provider id uppercased with non-alphanumerics as _, e.g. VEYYON_PROXY_GITHUB_COPILOT) overrides it for one provider. Localhost, RFC1918, link-local and cloud metadata hosts always bypass the proxy, as do NO_PROXY/no_proxy matches |
VEYYON_EVAL_SYSTEM_PROMPT_SECTIONS | Benchmark instrumentation, not an operator knob. A JSON object of section name to replacement text. When set, the benchmark payload becomes the only source of prompt sections and the run logs that the override is active. Invalid JSON fails the build |
VEYYON_EVAL_SYSTEM_PROMPT_STATEMENTS | The statement-level counterpart to the section override, same benchmark-only status. See system prompt architecture |
VEYYON_NO_PTY is also set internally when CLI --no-pty is used.
6) Storage and config root paths
These are consumed via @veyyon/utils/dirs and affect where coding-agent stores data.
| Variable | Default / behavior |
|---|---|
VEYYON_CONFIG_DIR | Config root dirname under home (default .veyyon). A name, not a path: an absolute value is rejected at startup. |
VEYYON_PROFILE | Activate a named profile (relocates the user base to ~/.veyyon/profiles/<name>) |
VEYYON_WORKTREE_DIR | Base directory for task-isolation worktrees (default ~/.veyyon/profiles/<name>/wt) |
VEYYON_GITHUB_CACHE_DB | Path override for the GitHub tool cache database |
VEYYON_AUTORESEARCH_DB_DIR | Directory override for the autoresearch database |
VEYYON_CODING_AGENT_DIR | Full override for the agent directory (default ~/<config dir>/profiles/<active-or-default>/agent) |
PWD | Used when matching canonical current working directory in path helpers |
7) Shell/tool execution environment
(From packages/utils/src/procmgr.ts and coding-agent bash tool integration.)
| Variable | Behavior |
|---|---|
VEYYON_BASH_NO_CI | Suppresses automatic CI=true injection into spawned shell env |
CLAUDE_BASH_NO_CI | Legacy alias fallback for VEYYON_BASH_NO_CI |
VEYYON_BASH_NO_LOGIN | Disables login-shell mode; shell args become ['-c'] instead of ['-l','-c'] |
CLAUDE_BASH_NO_LOGIN | Legacy alias fallback for VEYYON_BASH_NO_LOGIN |
VEYYON_SHELL_PREFIX | Optional command prefix wrapper |
CLAUDE_CODE_SHELL_PREFIX | Legacy alias fallback for VEYYON_SHELL_PREFIX |
VISUAL | Preferred external editor command |
EDITOR | Fallback external editor command |
Current implementation: VEYYON_BASH_NO_LOGIN/CLAUDE_BASH_NO_LOGIN are active; when either is set, getShellArgs() returns ['-c'].
8) UI/theme/session detection (auto-detected env)
These are read as runtime signals; they are usually set by the terminal/OS rather than manually configured.
| Variable | Used for |
|---|---|
COLORTERM, TERM, WT_SESSION | Color capability detection (theme color mode) |
COLORFGBG | Terminal background light/dark auto-detection |
TERM_PROGRAM, TERM_PROGRAM_VERSION, TERMINAL_EMULATOR | Terminal identity in system prompt/context |
TMUX_PANE, CMUX_SURFACE_ID, KITTY_WINDOW_ID, TERM_SESSION_ID, WT_SESSION | Stable per-terminal session breadcrumb IDs |
SHELL, ComSpec, TERM_PROGRAM, TERM | System info diagnostics |
APPDATA, XDG_CONFIG_HOME | lspmux config path resolution |
HOME | Path shortening in MCP command UI |
9) TUI runtime flags (shared package, affects coding-agent UX)
| Variable | Behavior |
|---|---|
VEYYON_NOTIFICATIONS | off / 0 / false suppress desktop notifications |
VEYYON_TUI_WRITE_LOG | If set, logs TUI writes to file |
VEYYON_HARDWARE_CURSOR | If 1, enables hardware cursor mode |
VEYYON_NO_SYNC_OUTPUT | If set (any non-empty value), disables DEC 2026 synchronized-output wrappers while keeping TUI autowrap guards |
VEYYON_TUI_SYNC_OUTPUT | 0 disables synchronized output, 1 forces it on. It shares one override tier with VEYYON_NO_SYNC_OUTPUT and VEYYON_FORCE_SYNC_OUTPUT, and an opt-out always beats a force-on. With no override the default comes from TERM_FEATURES, WT_SESSION, a terminal allowlist, and then a runtime DECRQM probe |
VEYYON_FORCE_SYNC_OUTPUT | 1 forces synchronized output on, unless an opt-out is also set |
VEYYON_TUI_SCROLL_TRANSPORT | alt-arrows releases the mouse grab and moves the transcript to the alternate screen with Alternate Scroll Mode, so the terminal keeps native selection and sends wheel ticks as cursor keys. Any other value keeps the default mouse transport |
VEYYON_NO_DECCARA | If set (truthy), disables Kitty DECCARA rectangular-SGR background fills (forces padded-string rendering) |
VEYYON_DEBUG_REDRAW | If 1, enables redraw debug logging |
VEYYON_FORCE_IMAGE_PROTOCOL | Forces terminal image protocol detection (kitty, iterm2/iterm, sixel, none) |
VEYYON_TUI_RESIZE_IN_PLACE | 1/true force in-place resize (no alt-screen borrow, no ED3 rewrap); 0/false force the alt-screen fast path. Default-on for Warp, which re-reports its size on alt-screen toggles |
10) Commit generation controls
| Variable | Behavior |
|---|---|
VEYYON_COMMIT_TEST_FALLBACK | If true (case-insensitive), force commit fallback generation path |
VEYYON_COMMIT_NO_FALLBACK | If true, disables fallback when agent returns no proposal |
VEYYON_COMMIT_MAP_REDUCE | If false, disables map-reduce commit analysis path |
DEBUG | If set, commit agent error stack traces are printed |
Security-sensitive variables
Treat these as secrets; do not log or commit them:
- Provider/API keys and OAuth/bearer credentials (all
*_API_KEY,*_TOKEN, OAuth access/refresh tokens) - Cloud credentials (
AWS_*,GOOGLE_APPLICATION_CREDENTIALSpath may expose service-account material) - Search/provider auth vars (
EXA_API_KEY,BRAVE_API_KEY,PERPLEXITY_API_KEY, Anthropic search keys) - Foundry mTLS material (
CLAUDE_CODE_CLIENT_CERT,CLAUDE_CODE_CLIENT_KEY,NODE_EXTRA_CA_CERTSwhen it points to private CA bundles) VEYYON_REQ_DEBUGis not a secret itself, but therr-session-*.jsondumps it writes into the working directory record request headers verbatim, so a dump carries whatever credential authenticated the request. Delete the files or keep them out of the repository
Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (packages/coding-agent/src/eval/py/runtime.ts).
Settings
veyyon resolves settings from built-in defaults, a persistent profile config file, a small machine-global file, one-shot CLI overlays, and in-memory runtime overrides. When one repository needs a different provider set, model role, tool policy, or UI behavior than your profile defaults, use a --config overlay or a path-scoped array (see Path-scoped arrays); both are covered below.
A repository never configures the agent. A checked-in .veyyon/config.yml or .veyyon/settings.json in a working tree is not read, because a repository is content you may not have written. The only files a project contributes are context files (AGENTS.md / CLAUDE.md), which are prose the model reads, not settings; see Context files.
Settings are stored as plain YAML mappings. Every key, its type, default, and enum values come from the settings schema, and you can inspect or change any of them with veyyon config or the interactive /settings panel.
- For model/provider credentials,
.envfiles, and the env-var table that resolves API keys, see Providers. - For custom model definitions in
models.yml, see Models. - For instruction files discovered into the agent context (
AGENTS.md,.veyyon/, etc.), see Context files. - For the full catalog of environment variables, see Environment variables.
Where settings live
| Scope | Path | Read behavior | Write behavior |
|---|---|---|---|
| Profile | ~/.veyyon/profiles/<name>/agent/config.yml | The main persistent settings file for the active profile. Always loaded. | /settings, veyyon config set, and veyyon config reset write here. |
| Profile legacy | ~/.veyyon/profiles/<name>/agent/settings.json | Migrated into config.yml once, only when config.yml does not yet exist. | Not written after migration; the original is renamed to settings.json.bak. |
| Machine-global (all profiles) | ~/.veyyon/config.yml | A small set of values shared by every profile: defaultProfile (which profile a bare vey launches), profileSharing (whether provider credentials are shared across profiles), and the auth-broker keys authBrokerUrl / authBrokerToken. Read live. | The Global tab of /settings, or veyyon profile default for defaultProfile. These keys never land in a profile’s own config.yml. |
| CLI overlay | Any file passed with --config <file> | Loaded after the profile config, for that one process. Repeatable. | Never persisted. |
| Runtime overrides | In-memory only | Set by dedicated CLI flags (--model, --approval-mode, …) and feature env vars. | Never persisted. |
VEYYON_CODING_AGENT_DIR relocates the ~/.veyyon/profiles/default/agent base directory. When it is set, the global config.yml, the auth store (agent.db), and everything else under the agent directory move with it. Use veyyon config path to print the active agent directory.
There is no project layer. Settings discovery reads home directories only: the active profile’s agent directory and the machine-global ~/.veyyon/config.yml. A .veyyon/ directory inside a working tree is never consulted for settings, whatever it contains.
Config file formats
The global config.yml is always YAML. The generic config loader used for other files (for example models.yml) accepts .yml, .yaml, .json, and .jsonc:
- When a
.yml/.yamlpath is requested and only a sibling.jsonexists, it is migrated to YAML automatically (idempotent, once per process). .jsonand.jsoncconfigs are read as-is, with no migration.- A file whose top level is not a mapping (a bare array or scalar) is treated as empty for persistent settings, and is a hard error for
--configoverlays.
Nested and flat keys
A setting can be written either way, and the two mean the same thing:
subagent:
model: openai/gpt-5
subagent.model: openai/gpt-5 # the same setting
The nested form is the one this documentation uses and the one every write from
/settings and veyyon config set produces. A flat key is expanded into the nested
form when the file is read, so you can type it either way.
Two rules cover the corners:
- If a setting is written both ways, the nested value wins, the flat key is dropped from the file the next time it is written, and a warning states both values.
- A key this build does not recognize is left exactly as written, whether or not it has dots in it. That keeps a config usable across versions and alongside other tools.
Reading and writing settings
Use the interactive /settings panel inside a session, or the veyyon config command from a shell. Both operate on the merged effective settings, and every persistent write lands in the global profile file, with one exception: the machine-global values on the Global tab (defaultProfile, profileSharing) write to ~/.veyyon/config.yml so they apply to every profile.
veyyon config list # all settings with current effective values
veyyon config list --json # same, machine-readable
veyyon config get theme.dark # one value
veyyon config get theme.dark --json
veyyon config set compaction.enabled false
veyyon config set compaction.model anthropic/claude-haiku-4-5
veyyon config reset steeringMode # restore a key to its schema default
veyyon config path # print the active agent directory
For users who want the full first-run animation on normal launches, set startup.showSplash:
veyyon config set startup.showSplash true
This only controls the startup splash animation. It does not rerun setup or change setup state, and startup.quiet: true still suppresses all startup chrome including the splash.
Subcommands
| Command | Effect |
|---|---|
veyyon config list | Print every setting grouped by tab, with its current value and type. --json emits an object keyed by setting path with { value, type, description }. |
veyyon config get <key> | Print the effective value of one key. Unknown keys exit non-zero. --json emits { key, value, type, description }. |
veyyon config set <key> <value> | Parse <value> against the key’s schema type and write it to the global config.yml. |
veyyon config reset <key> | Remove the key from the profile config.yml, so the schema default (or an overlay or runtime value) applies again. Reset deletes the key; it does not write the default into the file. |
veyyon config path | Print the active agent directory (honors VEYYON_CODING_AGENT_DIR). |
veyyon config init-xdg | Create the XDG data/state/cache directories Veyyon uses on Linux/macOS. |
A setting that has been replaced by another is retired: it stays readable and settable so an existing config keeps working, and the migration on load can read it, but veyyon config list leaves it out and veyyon config get/set name the key that governs the behavior now. The retired keys today are compaction.thresholdTokens and compaction.thresholdPercent (replaced by compaction.threshold) and defaultThinkingLevel (replaced by defaultEffort).
veyyon config with no subcommand is an alias for veyyon config list; --help prints the help. The --json flag is accepted by list, get, set, and reset.
Value parsing
veyyon config set parses the value string according to the target key’s schema type. The string is trimmed first.
| Type | Accepted input | Notes |
|---|---|---|
| boolean | true, false, yes, no, on, off, 1, 0 | Case-insensitive. Anything else is rejected. |
| number | Any finite JavaScript number | Infinity/NaN are rejected. |
| enum | One of the key’s allowed values | Must match exactly; the error lists the valid values. |
| array | A JSON array | e.g. '["anthropic","openai"]'. Must parse and be an array. |
| record | A JSON object | e.g. '{"bash":"prompt"}'. Must parse and be a non-array object. |
| string | Stored as given (trimmed) | Multi-word values are joined with spaces. |
Keys must match a real schema path exactly. There is no shorthand, set theme.dark, not theme.
Where writes go
veyyon config set, veyyon config reset, /settings, and any runtime settings change all write to the config.yml under the active agent directory. To vary behavior per repository, use a --config overlay or a path-scoped array (see Path-scoped arrays); a .veyyon/config.yml inside a repository is never read. Saves are debounced and re-read the file under a lock, so external edits made while a session is open are preserved. The machine-global keys on the Global tab (defaultProfile, profileSharing) are the exception: they write to ~/.veyyon/config.yml instead of the active agent directory, and are read live so an external edit to that file is reflected without a restart.
/settings shows the effective value from the full precedence chain. A row
supplied by a --config file or a runtime override states that source beside
the value and is read-only. Change the owning source instead.
This prevents an accepted-looking profile edit from remaining hidden until the
higher layer disappears.
Default Model is intentionally profile-owned. If --model or another
higher layer selects a different active model, the row shows both the saved
profile model and the active override. Editing the row changes the model used
by the next session; it does not replace the current session override.
Within one open panel, each category remembers its last selected row. Switch to another sidebar category and back to resume where you left off. If a condition hides that row, the panel selects the nearest available setting instead.
Precedence
From lowest to highest priority, the effective value of a setting is built as:
built-in defaults <- profile config <- CLI overlays <- runtime overrides
From highest to lowest:
- Runtime overrides: dedicated CLI flags and feature env vars applied in memory for the current process:
--model,--smol,--slow,--plan,--approval-mode,--auto-approve/--yolo,--hide-thinking,--advisor,--no-pty,--api-key, and protocol-mode defaults. Never persisted. - CLI config overlays: each
--config <file>; later overlay files override earlier ones. - Profile settings:
~/.veyyon/profiles/<name>/agent/config.yml. - Built-in defaults: from the settings schema.
A key that is unset at every layer resolves to its schema default at read time.
Environment overrides
Environment variables are not a single settings layer. Each is read by the feature that uses the value, usually as a per-machine override or fallback, and is never written back to config.yml. The ones that map directly onto a setting:
| Env var | Overrides setting | Notes |
|---|---|---|
VEYYON_SMOL_MODEL | modelRoles.smol | Also exposed as --smol. |
VEYYON_SLOW_MODEL | modelRoles.slow | Also exposed as --slow. |
VEYYON_PLAN_MODEL | modelRoles.plan | Also exposed as --plan. |
VEYYON_NO_PTY=1 | (disables PTY bash) | Equivalent to --no-pty for the process. |
VEYYON_PY | eval.py | VEYYON_PY=0 disables the Python eval backend. |
VEYYON_JS | eval.js | VEYYON_JS=0 disables the JavaScript eval backend. |
VEYYON_TINY_DEVICE | providers.tinyModelDevice | ONNX execution provider for local tiny models. |
VEYYON_TINY_DTYPE | providers.tinyModelDtype | ONNX precision for local tiny models. |
VEYYON_AUTH_BROKER_URL | auth.broker.url | Env value takes precedence over config. |
VEYYON_AUTH_BROKER_TOKEN | auth.broker.token | Env value takes precedence over config. |
VEYYON_CODING_AGENT_DIR | (relocates agent dir) | Moves config.yml, agent.db, and the whole agent base. |
Provider API keys are resolved separately (stored auth, OAuth, models.yml, environment, and .env files); see Providers and the full Environment variables reference.
Merge rules
Layers are combined with a deep merge:
- Objects are deep-merged: keys present only in a lower layer are kept; keys present in a higher layer override.
- Scalars and arrays are replaced wholesale by the higher-precedence layer. A higher layer’s array does not append to a lower layer’s array.
Use nested YAML mappings for dotted setting paths:
theme:
dark: titanium
light: light
tools:
approvalMode: ask-command
approval:
bash: prompt
read: allow
Worked example: profile vs. overlay
# ~/.veyyon/profiles/default/agent/config.yml
tools:
approvalMode: ask-command
approval:
bash: prompt
read: allow
disabledProviders:
- anthropic
- openai
- gemini
# ./ci-overrides.yml, passed with --config
tools:
approval:
bash: allow
disabledProviders:
- groq
Effective settings for that process:
tools:
approvalMode: ask-command # kept from the profile (object deep-merge)
approval:
bash: allow # overridden by the overlay
read: allow # kept from the profile
disabledProviders:
- groq # the overlay array REPLACES the profile array
Array replacement is the most common surprise: the overlay’s disabledProviders does not extend the profile list, it becomes the entire list for that process. The same applies to enabledModels, cycleOrder, extensions, and every other array-typed setting.
Per-repository settings
A repository cannot carry its own settings: a checked-in .veyyon/config.yml is not read, because a working tree is content you may not have written. Two mechanisms cover what project config used to do:
--configoverlays apply a file you choose to one process, so a per-repo launcher or alias can pass the repo’s overlay explicitly:
veyyon --config ./local/repo-settings.yml "check this failure"
veyyon --config ./base.yml --config ./experiment.yml "try this model"
Overlay paths are resolved relative to the process working directory (and ~ is expanded). Each overlay must parse as a YAML mapping; a missing file, invalid YAML, or a top-level array/scalar is a hard error, it does not silently fall back to lower-precedence settings. Keep the overlay file out of commits if it holds anything private.
- Path-scoped arrays let one profile config behave differently per directory; see below.
Path-scoped arrays
Two array settings, enabledModels and disabledProviders, accept path-scoped entries in addition to bare strings, so a single global config can behave differently per directory:
enabledModels:
- claude-sonnet-4-5 # applies everywhere
- path: ~/work/high-context
models:
- anthropic/claude-opus-4-5
disabledProviders:
- ollama # applies everywhere
- paths:
- ~/projects/sensitive
- ~/clients/acme
providers:
- anthropic
- openai
Bare string entries apply everywhere. A scoped entry applies when the current working directory is the configured path or is under it. ~ expands to your home directory and relative paths are resolved before matching.
Accepted path keys (any of them, combined): path, paths, pathPrefix, pathPrefixes.
Accepted value keys:
models(forenabledModels) orproviders(fordisabledProviders)valuesoritems(for either setting)
Only string values are kept; malformed scoped entries are ignored. Path scoping is resolved after the layer merge, so it reads the final effective array.
Provider and source disabling
disabledProviders is a single shared id namespace that gates two different subsystems, before any credential check:
| Entry kind | Example ids | Effect |
|---|---|---|
| Model providers | anthropic, openai, google, groq, ollama, openrouter | Removes those backends from model selection, even when credentials are available. See Providers. |
| Discovery sources | native, claude, codex, gemini, github, opencode, cursor, agents, agents-md | Stops that source from contributing context files, MCP servers, commands, skills, hooks, tools, prompts, or settings. See Context files. |
Most provider-control use cases list model provider ids. Disabling the claude discovery source is different from disabling the anthropic model provider, one stops Claude-format config discovery, the other stops the Anthropic model backend.
Because arrays replace rather than append, an overlay that sets disabledProviders must list the complete desired set:
# ~/.veyyon/profiles/default/agent/config.yml
disabledProviders:
- anthropic
- openai
# ./ci-overrides.yml, passed with --config: for that process ONLY groq is disabled
disabledProviders:
- groq
The default is an empty array (nothing disabled). For the two subsystems’ provider ids and ordering, see Providers and Context files.
Settings catalog
Every key below is defined in the settings schema; veyyon config list shows the full set with current values. Defaults and enum values are taken from the schema. Settings that accept an env or flag override are noted; those overrides are process-local and not persisted.
Models
modelRoles, modelTags, and cycleOrder work together. Role values may carry a thinking suffix (:off, :auto, :minimal, :low, :medium, :high, :xhigh, :max). The same suffix works on subagent.model and compaction.model, so any model slot can run at a chosen effort.
A suffix on a role use overrides the role’s stored suffix. For example, if modelRoles.slow is anthropic/claude-opus-5:low, then @slow:high resolves to anthropic/claude-opus-5:high, not a double-suffixed model id.
When you pick a role, subagent, or compaction model in /settings, Veyyon opens a separate effort step only if that model exposes configurable effort. The first row, Model default, stores no suffix. The remaining rows contain auto, off when the model permits it, and only the model’s catalog-defined effort variants. For example, a low/high Gemini model does not show medium or xhigh. A fixed-reasoning model skips the effort step. The Default Model picker is deliberately model-only: it stores a bare selector, and Default Effort is the one UI surface for its saved effort. Providers sometimes publish effort tiers as separate upstream model IDs. Veyyon collapses effort-only siblings into one logical model and routes the selected effort to the correct upstream ID.
compaction.model and subagent.model are ordered chains. The first entry is the primary model and later entries are fallbacks. Enter edits the highlighted position, Add fallback appends a position, and Delete removes only the highlighted position. The settings rows show a stored effort as · high instead of the raw :high suffix.
The model you are working with (the main conversation) is persisted as modelRoles.default. That slot is not a selectable role: it is hidden from role pickers and stripped from cycleOrder on load. In the code it has one name, DEFAULT_MODEL_SLOT, and interactive is accepted as an alias for it wherever a role is passed. Selectable built-in roles: smol, slow, vision, plan, designer, commit, tiny, advisor. There is no task role: the model your subagents run lives in Subagents, which is its one owner.
modelRoles:
default: anthropic/claude-sonnet-4-5 # interactive model (persisted default)
smol: openai/gpt-4.1-mini
slow: anthropic/claude-opus-4-5:high
vision: gemini/gemini-3-pro-preview
plan: anthropic/claude-opus-4-5
advisor: anthropic/claude-sonnet-4-5:medium
cycleOrder:
- smol
- slow
subagent:
model: deepseek/deepseek-chat:high # optional; unset means subagents inherit your model; :effort optional
compaction:
model: openai/gpt-5-mini # optional; else inherits your current model; may carry :effort
modelProviderOrder:
- anthropic
- openai
enabledModels:
- claude-sonnet-4-5
| Key | Type | Default | Notes |
|---|---|---|---|
modelRoles | record | {} | Role name → model id. Interactive model uses key default (hidden in UI). Selectable built-ins: smol, slow, vision, plan, designer, commit, tiny, advisor. tiny is used for lightweight background tasks when set, else @smol. Launch: --model (interactive), --smol, --slow, --plan; advisor via modelRoles.advisor + advisor.enabled / --advisor. |
modelTags | record | {} | Custom role/tag metadata; can introduce additional roles. |
modelProviderOrder | array | [] | Preferred provider order when a model id is ambiguous. |
cycleOrder | array | ["smol","slow"] | Roles cycled by the model switcher (app.model.cycleForward, often Ctrl+P). The entry default is dropped on load. |
enabledModels | array | [] | Allow-list of models; supports path-scoped entries. Empty means all available models. |
disabledProviders | array | [] | Disabled model/discovery providers; supports path-scoped entries. See above. |
includeModelInPrompt | boolean | false | Include the active model name in the system prompt. Off by default: the name sits in the cached prefix, so switching models re-prefills the whole block. |
See Models for the models.yml schema and custom-provider definitions. Handbook: Models, roles, and profiles (under docs/handbook/src/using/).
Advisor
The advisor is a second model that reviews each completed turn and can inject advice into the primary session. Assign a model with modelRoles.advisor, then enable it with advisor.enabled or by launching with the --advisor flag.
See Advisor and WATCHDOG.md for runtime behavior, WATCHDOG.md discovery, and bounded catch-up semantics.
| Key | Type | Default | Notes |
|---|---|---|---|
advisor.enabled | boolean | false | Enable the advisor runtime when modelRoles.advisor resolves to an available model. |
advisor.subagents | boolean | false | Also enable advisor runtimes for spawned task/eval subagents. |
advisor.syncBacklog | enum | off | Bounded advisor catch-up delay: off, 1, 3, or 5. The primary waits up to 30 seconds only while advisor backlog is at or above the threshold. |
advisor.immuneTurns | number | 3 | After a concern/blocker interrupts, route further concerns/blockers as non-interrupting asides for this many completed primary turns. |
Thinking
Effort has one persisted home: the defaultEffort list, per profile. A row keyed
by a model selector applies to that model; the * row applies to every model
without its own. /effort (and its /thinking alias) changes only the current
session and prints where the saved default lives, so trying an effort never
rewrites your default.
The retired defaultThinkingLevel is consulted only when the defaultEffort key is absent. Once defaultEffort is present, its object is authoritative, including {} and a set of model-specific rows with no * fallback. Removing the Any Model row therefore keeps every unmatched model on its native default instead of resurrecting a legacy profile-wide value.
Choose Default in the session effort picker to clear the temporary override.
Veyyon then applies an explicit :level on the active selector, the active
model’s saved row, the * row, or the model default according to the precedence
below. Switching models re-evaluates these sources. A temporary session choice
remains in force until you clear it.
Effort is resolved in this order, highest first:
- the current session’s choice, from
/effort,/thinking, or the cycle keybinding - an explicit
:levelon the selector a role resolved through, e.g.modelRoles.plan: anthropic/claude-opus-5:xhigh - the
defaultEffortrow for the model about to run - the
defaultEffort*row - the model’s own default, when nothing above is set
defaultEffort:
"*": high
anthropic/claude-haiku-4-5: low
hideThinkingBlock: false
thinkingBudgets:
minimal: 1024
low: 2048
medium: 8192
high: 16384
xhigh: 32768
max: 32768
| Key | Type | Default | Values |
|---|---|---|---|
defaultEffort | record | {} | Effort per model, applied when a run does not ask for one. Keys are model selectors (anthropic/claude-opus-5) or * for any model; values are minimal, low, medium, high, xhigh, max, auto, or off. Edit it in /settings → Model → Default Effort. |
defaultThinkingLevel | enum | high | Retired in favour of defaultEffort’s * row. It is read only when the replacement defaultEffort key is absent, so an existing profile migrates without overriding an explicitly empty or model-only list. No settings row of its own. |
hideThinkingBlock | boolean | false | Hide thinking blocks in output. --hide-thinking sets it for the run (display only). |
thinkingBudgets.minimal | number | 1024 | Token budget for the minimal level. |
thinkingBudgets.low | number | 2048 | Token budget for low. |
thinkingBudgets.medium | number | 8192 | Token budget for medium. |
thinkingBudgets.high | number | 16384 | Token budget for high. |
thinkingBudgets.xhigh | number | 32768 | Token budget for xhigh. |
thinkingBudgets.max | number | 32768 | Token budget for max. |
Sampling
These settings are unset by default, and unset means the key is absent from config.yml: veyyon then does not send that parameter and the provider uses its own default. Every number you write is sent as written, including negatives: presencePenalty: -1 and repetitionPenalty: -0.5 both reach the provider. In /settings the unset state is the row labelled Default, and choosing it removes the key rather than storing a value.
Earlier versions stored -1 to mean unset, which made -1 itself impossible to configure. Your global config is migrated once: a -1 on one of these keys is dropped, and the config records that the migration ran (settingsMigrationVersion), so a -1 you set afterwards is kept. A --config overlay is never rewritten and is read as written, so a -1 there is the value -1.
Set a negative value from the command line the way you would any other:
veyyon config set presencePenalty -1
| Key | Type | Default | Notes |
|---|---|---|---|
temperature | number | (unset) | Sampling temperature. 0 is deterministic. |
topP | number | (unset) | Nucleus sampling. |
topK | number | (unset) | Top-K sampling. |
minP | number | (unset) | Minimum-probability cutoff. |
presencePenalty | number | (unset) | Presence penalty. Negative values, including -1, are sent as written. |
repetitionPenalty | number | (unset) | Repetition penalty. Values below 1 encourage repetition and are sent as written. |
tier.openai | enum | none | none, auto, default, flex, scale, priority. Sent as service_tier for OpenAI / OpenAI-Codex and OpenAI-family OpenRouter models. |
tier.anthropic | enum | none | none, priority. priority realizes fast mode on supported direct Claude models (ignored on Bedrock/Vertex and via OpenRouter). |
tier.google | enum | none | none, flex, priority. Gemini API sends it in the body; Vertex sends priority via header (flex is a no-op on Vertex). |
tier.subagent | enum | inherit | inherit, none, auto, default, flex, scale, priority. Applied to the spawned model’s family; inherit tracks the main agent. |
tier.advisor | enum | none | inherit, none, auto, default, flex, scale, priority. Applied to the advisor model’s family. |
personality | string | default | Communication style rendered into the system prompt. Built in: default, friendly, pragmatic, none. Not a closed set: add your own with ~/.veyyon/personalities/<name>.md, or .veyyon/personalities/<name>.md in a project. |
Retry and fallback
retry:
enabled: true
maxRetries: 10
baseDelayMs: 500
maxDelayMs: 300000
modelFallback: true
fallbackRevertPolicy: cooldown-expiry
fallbackChains:
# Any role without an explicit chain inherits the "default" chain.
default:
- anthropic/claude-opus-4-5
- openai/gpt-5.5
- google/gemini-3-pro
# Per-role chains override the default (roles from `modelRoles`,
# including custom roles). Selectors accept an optional thinking
# suffix, e.g. openai/gpt-5.5:low.
smol:
- openai/gpt-5.5-mini
- anthropic/claude-haiku-4-5
# Model-selector keys (any key containing "/") attach the chain to the
# model itself: it applies whenever that model is active, no matter
# which role it is assigned to, and survives role reassignment.
google/gemini-3-pro:
- google-vertex/gemini-3-pro
# A `provider/*` KEY covers every model of a provider: current or
# future. A `provider/*` ENTRY keeps the failing model's id and swaps
# the provider: google-antigravity/x -> google/x -> google-vertex/x.
# Ids missing on the target provider are skipped (near-miss ids resolve
# fuzzily); exact model keys override the wildcard for a specific model.
google-antigravity/*:
- google/*
- google-vertex/*
| Key | Type | Default | Notes |
|---|---|---|---|
retry.enabled | boolean | true | Retry transient provider errors. |
retry.maxRetries | number | 10 | Max retries per request. |
retry.baseDelayMs | number | 500 | Initial backoff. |
retry.maxDelayMs | number | 300000 | Backoff ceiling (5 min). |
retry.modelFallback | boolean | true | Fall back to another model when one is unavailable. |
retry.fallbackChains | record | {} | Maps roles, model selectors, or provider/* wildcards to ordered fallback selectors. Keys containing / are model-oriented and win over roles: provider/model-id matches that exact model, provider/* matches every model of the provider. A provider/* entry keeps the failing model’s id and swaps the provider. The default chain covers every assigned role without its own chain. Unknown models/providers or malformed chains are reported as config warnings at startup. |
retry.fallbackRevertPolicy | enum | cooldown-expiry | cooldown-expiry returns to the primary model once its suppression window ends; never stays on the fallback until switched manually. |
When the active model keeps failing (429s, quota walls, provider outages) and retry.modelFallback is on, the session picks the chain that includes the failing model, by specificity: an exact provider/model-id key, then a provider/* wildcard, then the current role’s chain, then default. It skips models whose selectors are still cooling down and switches for the rest of the turn. Subagents get their own per-spawn chains when their agent definition lists multiple model patterns, the first resolvable pattern is primary and the rest become its fallbacks; there is no agent:<name> key in fallbackChains.
Tools and approvals
tools:
approvalMode: auto # default
approval:
bash: prompt
edit: allow
discoveryMode: auto
maxTimeout: 0
intentTracing: true
| Key | Type | Default | Notes |
|---|---|---|---|
tools.approvalMode | enum | auto | Canonical: plan (read auto; write asks with an active plan-mode session, otherwise write/exec denied), ask (nothing auto; every tier asks, reads included), ask-command (read+write auto; exec ask), auto (all tiers auto, with the per-tool, working-directory, credential and critical-call guards still asking), yolo (all tiers auto). Legacy aliases still accepted: always-ask → ask, write and auto-edit → ask-command. Override per run with --approval-mode / --auto-approve / --yolo. |
tools.approval | record | {} | Per-tool policy keyed by tool name; each value is allow, deny, or prompt. Any other value denies that tool and is named in a startup warning. e.g. veyyon config set tools.approval '{"bash":"prompt"}'. |
tools.discoveryMode | enum | auto | auto, off, mcp-only, all. all hides non-essential built-ins and first-party heavyweight tools such as generate_image until the discovery search activates them. |
tools.essentialOverride | array | [] | Tool names kept available even when tools are narrowed. |
tools.maxTimeout | number | 0 | Max tool runtime in seconds; 0 = no cap. |
tools.intentTracing | boolean | true | Record per-call intent strings. |
tools.outputMaxColumns | number | 768 | Per-line byte cap for streaming output; 0 disables. |
tools.artifactSpillThreshold | number | 50 | KB of tool output above which output spills to an artifact, for every tool including the streaming ones (bash, eval, ssh, interactive shell). The result keeps a window no larger than this, plus the artifact:// id that reads the full text back. |
tools.artifactHeadBytes | number | 20 | KB of head kept inline on spill, bounded with the tail by the threshold; 0 = tail-only. |
tools.artifactTailBytes | number | 20 | KB of tail kept inline on spill, bounded by the threshold. |
tools.artifactTailLines | number | 500 | Max tail lines kept inline on spill. |
Most optional built-in tools are toggled by their own keys, e.g. bash.enabled, launch.enabled, eval.py, eval.js, fetch.enabled, browser.enabled, astEdit.enabled, web_search.enabled, inspect_image.enabled. Workspace search is part of the default tool inventory.
Subagents
Everything about spawned agents lives here, under subagent.: whether this session
delegates at all, which agent types it may use, what model and effort they run, and
the limits and isolation they run under. In /settings it is the Subagents tab.
Three settings, three different questions
Subagents are governed by three settings, and mixing them up is the usual source of confusion, so read this table before you change anything. Each one answers a question the other two cannot.
| Setting | The question it answers | Default |
|---|---|---|
subagent.enabled | May this session use subagents at all? | true |
subagent.delegation | Is the model encouraged to fan work out, and how hard? | preferred |
subagent.agents | Which agents may it use? | task only |
Read them top to bottom. subagent.enabled is the master switch: turn it off and
there are no subagents, the task tool is not built, and the other two settings stop
mattering. Leave it on and subagent.delegation sets how much the prompt pushes,
while subagent.agents sets what there is to push work to.
Turning delegation down does not forbid delegation. This is the distinction that
matters most. subagent.delegation: allowed means the model still has the task
tool and will still spawn a subagent when that is the sensible move; the prompt does not request it. The only setting that takes the ability away is subagent.enabled. If you
want subagents gone, set that one, not this one.
subagent:
enabled: true # master switch; false removes subagents entirely
delegation: preferred # allowed | preferred | required
model: openai/gpt-5:high # optional; unset means inherit your model
thinkingLevel: medium # optional; unset means inherit your effort
agents:
scout:
enabled: true # let the model choose the scout
reviewer:
enabled: true
model: anthropic/claude-opus-4-5 # this agent only
maxConcurrency: 32
isolation:
mode: none
Out of the box you get one agent type, the general-purpose worker, and the prompt
encourages fanning work out to it. The bundled specialists (scout, reviewer,
designer, librarian, sonic) ship disabled: each one you enable adds its
description to every request, so you pay for the ones you actually use and nothing
else. They stay listed while disabled, each with a line saying what it is for, so you
can see what is available before you turn anything on.
Subagents on or off
subagent.enabled is a boolean and it is the only kill switch. When it is false:
- the
tasktool is not built, so the model cannot spawn anything; - every delegation instruction leaves the system prompt;
subagent.delegationandsubagent.agentsare still stored, still editable, and take effect again the moment you turn this back on.
Earlier releases spelled this as subagent.delegation: off, which made one setting
answer two questions: whether subagents existed, and how hard to push them. An
existing delegation: off is migrated to enabled: false with delegation left at
its default, because “off” was how you turned subagents off.
Delegation
subagent.delegation sets how hard this session pushes work out. It never removes
the ability to delegate; for that, see subagent.enabled above.
| Value | Behavior |
|---|---|
allowed | The tool is offered and the prompt does not request it. The model delegates when it judges that delegation helps. |
preferred | The default. The prompt instructs the model to fan substantial work out rather than doing it alone. |
required | The same, plus a first-turn reminder that delegation is the default here. |
What the model is told to delegate
The prompt does not carry a fixed list of delegable work. The agents you enable are the instruction. That is the whole mechanism, and it is why the Agents table is a delegation setting rather than a cosmetic one.
With only the worker enabled, the guidance is about splitting execution across
parallel workers and keeping bulk reading out of your session’s context. Nothing tells
the model to send research to a scout it cannot spawn, and nothing tells it to send
a review to a reviewer that does not exist. Enable the reviewer and you have said
reviews are delegable here; the prompt then lists it. Enable the scout and bulk
exploration becomes something it is told to route away from its own context.
This is also the answer to “why did it delegate my audit?”. If a specialist for that work is enabled, the model has been told the work is delegable. If none is, and it still fans out, that is a prompt bug rather than a settings question: file it.
Context preservation, not a cheaper model. A subagent usually runs the same model you are on (see Which model a subagent runs). What delegation buys is a separate context window: bulk reading, wide searches, and long tool output stay out of your session and come back as a summary. Nothing about delegation implies the subagent is less capable than you.
When the two settings disagree
subagent.delegation and the Agents table are one question with two answers, and one
resolver reads both. If you disable every agent there is nothing to delegate to, so
the strength you pick has no effect until you enable at least one: the prompt stops
asking for delegation, the first-turn reminder is not injected, and both agent
surfaces state it in a line above the table. If subagent.enabled is off, the same line
states that instead, because turning agents on would change nothing until you turn
subagents back on. Neither setting is hidden behind the other: you need all three
while setting up a session, but none pretends the others do not exist.
Agents
subagent.agents holds one row per agent, keyed by agent name. One surface edits it
rather than hand-written config: the Agents row in /settings → Subagents, which
lists every discovered agent with the model it resolves to and opens one agent at a
time to set its state. /agents used to carry a second copy of the same table, so the
same two facts had two homes that had to be kept in step; it is the live picture now
and configures nothing.
An agent is either enabled or disabled. There is no third state:
enabled | Meaning |
|---|---|
| absent | The shipped default: the worker and every agent you wrote yourself are enabled, the bundled specialists are disabled. |
true | Enabled. The agent is listed in the task tool description, and the model may choose it. |
false | Disabled. The model may not choose it, and a spawn that tries is rejected with the setting named. |
What “disabled” governs, and what it does not
Disabling an agent stops the model from choosing it. It does not stop you.
That distinction is the whole rule, and it is worth stating plainly because an earlier version of veyyon got it wrong. There used to be a middle state, shown as “not offered but still runs when named”, which meant a row could read as off while the agent went on running. Nobody could tell what the switch did. Enabled now means the model may pick the agent on its own initiative, disabled means it may not, and that is all it means.
Slash commands are you asking, so they are unaffected. Running /review is a request
for a review, not a suggestion that the model consider reviewing, so /review spawns
its reviewer even though reviewer ships disabled. A command declares the agents its
prompt names, and that declaration is granted for that one turn only:
| Command | Agent it names | Works with the agent disabled |
|---|---|---|
/review | reviewer | yes |
Two limits keep this narrow. The grant lasts for the turn the command starts and no longer, so the model cannot reach a disabled agent on the next turn. And it comes from the command’s own definition, not from anything computed while the command runs, so the list above is the complete list. If you ask for an agent in plain prose instead of through a command (“use the scout agent”), that is the model choosing, and a disabled scout is rejected.
A row contains whether the agent is enabled, how deep it may nest its own spawns, and the model and effort that agent runs. Every one of those is edited on the agent’s own page inside Roster.
Which model a subagent runs
The first row of Roster is Same Model for All Agents, and it picks which of two chains decides. It is off by default.
Off, each agent answers for itself. The first layer that specifies a model wins:
- the agent’s own lane,
subagent.agents.<name>.model, edited on that agent’s page. subagent.modelByDepth.<n>, for a spawn at exactly that depth.- the agent definition’s own
model:frontmatter, for an agent you wrote. - otherwise the subagent inherits the model you are working with.
On, one model answers for every agent: subagent.model, else the model you are working
with. Nothing per-agent is read, so the lanes, the depth rows and an agent file’s own
model: all stop applying. The agent rows stay listed and go grey, because which agents
are enabled is still decided there.
The shared model and its effort appear on screen only while the switch is on. Off, they are not shown at all: a greyed row displaying a model nobody runs is the duplication the switch exists to end. There is no Subagent Model row on the Models tab and none on the Subagents tab; one page owns the question.
None of the bundled agents pin a model, so on a fresh install every subagent runs the
model you are looking at. To move them all at once, turn the switch on and set the model
under it. To give one agent its own, open that agent in Roster, or write it in the
agent’s model: frontmatter.
A configured value that matches no available model does not fall through to the next layer. The spawn is rejected and the message states the setting to fix, because a silent fall-through is indistinguishable from your setting having no effect.
Effort rides the same switch, through subagent.thinkingLevel when shared and the
agent’s own lane when not. The levels offered are the ones the model in scope actually
exposes, so a model that routes effort through separate model ids offers Inherit
alone and states why, rather than listing levels it would reject. A value that matches
no level (from a hand-written config) is reported with the setting and the accepted
levels, then ignored. It is never rounded to a neighbouring effort: running at an effort
you did not choose costs money and would not show up anywhere.
Every surface that shows a subagent’s model also names the setting that decided it —
subagent.agents.deep.subagents, subagent.modelByDepth.2, subagent.model — so an
agent running something you did not expect is a question you can answer.
The two views in /agents
/agents opens the subagent dashboard, which is about a run in progress and
configures nothing. Move between its two views with the left and right arrows, with
tab, or by clicking a name in the strip at the top of the card:
| View | What it answers |
|---|---|
| Live | Which agents exist right now, what type each one is (reviewer, scout, the definition it was spawned from), and what it is doing. Agents from earlier runs of the session appear too, marked parked. Press enter on a row, or click it, to open that agent’s session in the main view: you read its transcript and can type to it, and esc returns you to your own session. Press x to stop an agent. |
| Comms | The agent-to-agent messages, streaming as they are sent, including the ones that failed to reach their recipient and why. Long messages are folded to their first few lines with a count of what was hidden; ctrl+o unfolds them. |
Live only ever lists agents that exist in this session, so a disabled specialist cannot appear there: it was never spawned. Which agents the model may choose, and what each one runs on, is configured in the Agents row of this tab.
/cockpit and /hub are aliases of /agents, as are the app.agents.hub and
app.session.observe keys and a double-tap of the left arrow on an empty composer.
They used to open a separate screen with its own roster, which meant two answers to
“which agents are running” that could disagree.
| Key | Type | Default | Notes |
|---|---|---|---|
subagent.enabled | boolean | true | The master switch. false removes subagents entirely: no task tool, no delegation guidance. See above. |
subagent.delegation | enum | preferred | allowed, preferred, required. How hard the prompt pushes; it never removes the ability to delegate. See above. |
subagent.agents | record | {} | One row per agent: enabled, model, thinkingLevel, maxNestedSpawnDepth, and a nested subagents row per level below. Edit in the Roster row of the Subagents tab. Ignored while subagent.sharedModel is on. |
subagent.sharedModel | boolean | false | Whether one model and effort answer for every subagent. On, subagent.model and subagent.thinkingLevel decide and nothing per-agent is read. Off, each agent runs what its own Roster page names. The first row of Roster; it has no row of its own on the tab. |
subagent.model | modelChain | unset | The shared model chain, live only while subagent.sharedModel is on. Tried in order, written as a comma-separated string or as a YAML list: the later entries are used when a run errors on the one in use. Unset means inherit: subagents follow the model you are working with. May carry a :effort suffix, and an explicit suffix wins over the agent’s own default. A pattern that matches no model rejects the spawn rather than falling through to the next entry. Edit inside Roster. |
subagent.modelByDepth | record | {} | One row per spawn depth ("1" is a direct child, "2" a grandchild), each a chain in the same shape as subagent.model. Applies only while subagent.sharedModel is off, and outranks the agent’s own frontmatter for a spawn at exactly that depth; other depths are unaffected. A row whose chain matches no model rejects the spawn and states the row. Edit in the Models by Depth row of the Subagents tab. |
subagent.thinkingLevel | string | unset | The shared effort, live only while subagent.sharedModel is on, picked from the levels the model in scope exposes. Unset or Inherit passes the current session’s effective effort into the child. It does not request auto from the provider. Edit inside Roster. |
subagent.batch | boolean | true | Batch shape for the task tool: one call, many items. |
subagent.maxConcurrency | number | 32 | Subagents running at once. |
subagent.maxNestedSpawnDepth | number | 0 | Nested levels that subagents may spawn. Direct children receive no task tool at 0; an agent-specific override may raise the limit. |
subagent.maxRuntimeMs | number | 0 | Hard per-subagent wall-clock limit in ms; 0 disables it. |
subagent.idleTtlMs | number | 300000 | How long a finished subagent stays live before parking. The default is 5 minutes for every model and provider. Set a positive millisecond value to override it. 0 keeps idle agents live until exit. Parking closes the live session but retains its transcript for revival. |
subagent.softRequestBudget | number | 200 | Requests after which a subagent is asked to wrap up; 0 disables the guard. |
subagent.softRequestBudgetNotice | boolean | true | Inject that wrap-up notice once. |
subagent.showResolvedModelBadge | boolean | true | Show each subagent’s resolved model, and what decided it, on the task widget and the agent surfaces. |
subagent.enableLsp | boolean | false | Let subagents use the lsp tool. |
subagent.isolation.mode | enum | none | Filesystem isolation backend for subagents. See Safety. |
subagent.isolation.merge | enum | patch | How isolated changes come back: patch or branch. |
subagent.isolation.commits | enum | generic | Commit message style for nested repo changes. |
Shell, eval, and LSP
bash:
enabled: true
autoBackground:
enabled: false
thresholdMs: 60000
stallDetection:
enabled: false
stallMs: 30000
eval:
py: true
js: true
python:
kernelMode: session # session, per-call
interpreter: ""
ruby:
kernelMode: session # session, per-call
julia:
kernelMode: session # session, per-call
lsp:
enabled: true
tool: true
lazy: true
diagnosticsOnWrite: true
diagnosticsOnEdit: false
formatOnWrite: false
| Key | Type | Default | Notes |
|---|---|---|---|
bash.enabled | boolean | true | Enable the bash tool. |
launch.enabled | boolean | true | Enable the launch tool for shared long-running project processes. |
bash.autoBackground.enabled | boolean | true | Auto-background long-running commands. You can also background the running command yourself with the composer’s background key, whatever this is set to. |
bash.autoBackground.thresholdMs | number | 300000 | Max wall-clock time a bash call runs in the foreground before it is moved to a background job. Frees the model and protects the prompt cache. Fires on elapsed time even while output streams. 0 backgrounds immediately. |
bash.stallDetection.enabled | boolean | false | Watch for a bash call that stops producing output; background it and tell the model it may be stuck so it can cancel a truly hung command. Recommends, never force-kills. |
bash.stallDetection.stallMs | number | 30000 | Idle time (no new output) before a bash call is treated as possibly stuck. Measures quiet output, not total run time. |
eval.py | boolean | true | Python eval backend. VEYYON_PY=0 disables for the process. |
eval.js | boolean | true | JavaScript eval backend. VEYYON_JS=0 disables for the process. |
python.kernelMode | enum | session | session (persistent kernel) or per-call. |
ruby.kernelMode | enum | session | Same choice for Ruby cells: keep one kernel per session, or start and shut down a kernel for each cell. |
julia.kernelMode | enum | session | Same choice for Julia cells. A fresh Julia kernel recompiles, so per-call trades startup time for a clean slate. |
python.interpreter | string | "" | Path to a Python interpreter; empty = auto-detect. |
lsp.enabled | boolean | false | Start language servers. Opt in; Files → LSP enters the nested switches. --no-lsp disables the whole stack for a run. |
lsp.tool | boolean | true | Expose the lsp tool to the agent. Independent of injected diagnostics. |
lsp.lazy | boolean | true | Start servers on demand. |
lsp.diagnosticsOnWrite | boolean | true | Inject diagnostics after a write. Independent of the agent tool. |
lsp.diagnosticsOnEdit | boolean | false | Inject diagnostics after an edit. Independent of the agent tool. |
lsp.formatOnWrite | boolean | false | Format files on write. |
lsp.diagnosticsDeduplicate | boolean | true | Collapse duplicate diagnostics. |
shellPath | string | (unset) | Override the shell binary used by bash. |
Files: editing and reading
edit:
mode: hashline # apply_patch, hashline, patch, replace
fuzzyMatch: true
fuzzyThreshold: 0.95
blockAutoGenerated: true
afterEdit: verify # verify, review, off
read:
defaultLimit: 300
toolResultPreview: false
summarize:
enabled: true
prose: false
| Key | Type | Default | Notes |
|---|---|---|---|
edit.mode | enum | hashline | apply_patch, hashline, patch, replace. |
edit.fuzzyMatch | boolean | true | Allow fuzzy anchor matching. |
edit.fuzzyThreshold | number | 0.95 | Similarity threshold for fuzzy matching. |
edit.blockAutoGenerated | boolean | true | Refuse to edit generated/lockfile-like files. |
edit.streamingAbort | boolean | false | Abort on streaming edit mismatch. |
edit.afterEdit | enum | verify | verify runs one check when none followed the last edit, review reads back every file the turn changed, off neither. |
read.defaultLimit | number | 300 | Default line count for read without a selector; the window also stops at the tool output budget. |
read.summarize.enabled | boolean | true | Structural summaries for code reads. |
read.summarize.prose | boolean | false | Summarize prose files too. |
read.toolResultPreview | boolean | false | Inline preview of tool results. |
readLineNumbers | boolean | false | Show plain line numbers. |
edit.afterEdit applies to the main agent; subagents are exempt from every value. verify continues once when the turn’s last successful edit has no later successful bash, eval, debug or browser result, and asks for one to be run. review continues once naming every code file changed since the last user message, and asks for a correctness, maintainability and cross-file contract pass; a file whose edit has left the context window is listed apart with an instruction to read it first. Documentation, lockfiles, binary files, media, archives and databases are not code files. Repeated calls for the same normalized path count once. A reply that ends with a question to the user defers both, and the window moves with the next user message, so changes made before a question are not reviewed after it. A configuration written before this setting existed carries a critiqueCodeMutations boolean under edit, which migrates on load: true becomes review, false becomes verify.
Automatic tool issue reports
Auto QA records a model’s report when a built-in tool behaves differently from its contract. Recording is local to the active profile. Automatic upload is a separate setting and is off by default.
dev:
autoqa: true
autoqaPush:
enabled: false
endpoint: https://veyyon.dev/api/grievances
Turn on Auto QA to create reports in the profile’s autoqa.db. Turn on
Auto-upload Grievances to send new and queued reports to the collector at veyyon.dev. You can
leave automatic upload off and inspect the queue with veyyon grievances. Running
veyyon grievances push is an explicit one-time upload and does not change the profile toggle.
Each profile defines its own recording and upload settings. The install identifier in an uploaded batch is shared across profiles so the collector can make a retried local row idempotent. It contains no hostname or username.
Context, compaction, and memory
contextPromotion:
enabled: false
compaction:
enabled: true
strategy: summary # the sole compaction strategy
midTurnEnabled: true # check thresholds between tool-loop provider requests
threshold: auto # auto | 85% (of the model's window) | 170000 (tokens, any model)
memory:
backend: off # off, local, hindsight, mnemopi
| Key | Type | Default | Notes |
|---|---|---|---|
contextPromotion.enabled | boolean | false | Promote to a larger-context model on overflow instead of compacting. |
compaction.enabled | boolean | true | Automatic conversation compaction. |
compaction.midTurnEnabled | boolean | true | Check thresholds at safe mid-turn tool-loop boundaries before the next provider request. |
compaction.strategy | enum | summary | The sole strategy. It rewrites old history into an in-place LLM summary. Stored legacy values migrate to summary; use /handoff for an explicit new-session transfer. |
compaction.model | modelChain | unset | Models for LLM compaction, tried in order, written as a comma-separated string or as a YAML list; unset inherits the model you are working with (modelRoles.default). Each may carry a :effort suffix, applied on every compaction pass. A candidate that is unauthenticated, or whose window cannot hold the summary, is skipped and the next one runs. |
compaction.modelFallbackStrategy | enum | auto | What to try after compaction.model runs out. auto stays on models you named: the main model, its same-provider compaction sibling, then each model role. any-model goes further, to the largest-window model on any provider you hold credentials for, which can bill an account you were not using for this session. configured-only stops at the models you listed and fails with the reason. Compacting on anything but your first choice is reported in the session, once per reason. |
compaction.threshold | string | auto | When auto-compaction triggers, with the unit in the value: auto uses contextWindow - max(15% of contextWindow, reserveTokens); 85% is a percent of the current model’s window, so the trigger moves with the model; 170000 is an absolute token amount, the same trigger on every model. An absolute amount larger than the current model’s window is honored up to contextWindow - 1 and you get a one-time warning. Set it in /settings -> Model -> Auto-Compaction Threshold. |
compaction.thresholdTokens | number | -1 | Retired, replaced by compaction.threshold. A value > 0 in your global config is rewritten to threshold: <amount> on load and this key is dropped, so your trigger point does not change. Write an absolute amount as threshold: 170000. |
compaction.thresholdPercent | number | -1 | Retired, replaced by compaction.threshold. A value > 0 is rewritten to threshold: <percent>% on load (the token amount above wins when both are set) and this key is dropped. Write a percent as threshold: 85%. |
compaction.remoteEndpoint | string | unset | Optional summarizer endpoint for the summary strategy. It must return summary text, which is stored exactly like a locally generated summary. It is a transport, not a third strategy. |
memory.backend | enum | off | off, local, hindsight, mnemopi. Each backend has its own hindsight.* / mnemopi.* / memories.* tuning keys. |
autolearn.enabled | boolean | false | Experimental: after the agent stops, nudge it to capture lessons to memory and create/enhance isolated managed skills under ~/.veyyon/profiles/default/agent/managed-skills. Enables the manage_skill tool (and learn when a memory backend is active). |
autolearn.autoContinue | boolean | false | When autolearn.enabled, auto-run one capture turn at stop (uses extra tokens). Off = a passive reminder rides your next turn. |
autolearn.minToolCalls | number | 5 | Only nudge after a turn that used at least this many tools. |
session.instrumentation | enum | off | How densely a run records study records on the session file, for after-the-fact analysis and backtesting. Graded: off stores nothing extra; basic adds wall-clock (start, end, duration, and time-to-first-token for model turns); rich adds output weight (result bytes/tokens) and per-turn throughput (tokens/sec); ultra adds an arguments fingerprint, cache read/write tokens, reasoning tokens, and upstream provider. It records BOTH per-tool-call metrics (message.metrics) AND per-model-turn metrics and the exact request params sent (message.turnMetrics / message.request). The dev profile preset (veyyon profile new dev --from dev) sets this to ultra. See the session instrumentation reference for the on-disk field tables and jq recipes. |
compaction has additional tuning keys (idle compaction, supersede/drop heuristics) visible in veyyon config list. See Compaction for the full strategy reference.
Appearance and terminal
theme:
dark: titanium
light: light
symbolPreset: unicode # unicode, nerd, ascii
colorBlindMode: false
statusLine:
preset: default # default, minimal, compact, full, nerd, ascii, custom
separator: powerline-thin
transparent: false
showHookStatus: true
terminal:
showImages: true
images:
autoResize: true
blockImages: false
tui:
hyperlinks: auto # off, auto, always
| Key | Type | Default | Values |
|---|---|---|---|
theme.dark | string | titanium | Theme used on a dark terminal background. |
theme.light | string | light | Theme used on a light terminal background. |
symbolPreset | enum | unicode | unicode, nerd, ascii. |
colorBlindMode | boolean | false | Use blue instead of green for diff additions. |
showHardwareCursor | boolean | true | Show the terminal hardware cursor. |
statusLine.enabled | boolean | true | Show the composer footline, the quiet metadata row under the composer (profile, model, account, secrets, mode, path, git, context gauge, MCP boot health, draft token count). Off hides the row and skips the work behind it. |
statusLine.preset | enum | default | default, minimal, compact, full, nerd, ascii, custom. |
statusLine.separator | enum | pipe | Retired. It styled the powerline status bar that the composer footline replaced, and the footline joins its segments with a fixed · of its own. The key still loads so an existing config file is accepted, no value changes anything on screen, and it has no settings row. |
statusLine.sessionAccent | boolean | true | Tint the editor border with the session color. |
statusLine.transparent | boolean | true | Retired, with statusLine.separator. It governed the theme background fill and the powerline end caps of the deleted status bar; the footline paints no background at all. |
statusLine.showHookStatus | boolean | true | Show hook status messages. |
statusLine.showAccount | boolean | false | Name the account serving the next request on the footline, when the active provider stores more than one credential. Off by default; /account answers the same question on demand. Hidden while the footline is off. |
terminal.showImages | boolean | true | Render images inline (when the terminal supports it). |
images.autoResize | boolean | true | Resize large images for model compatibility. |
images.blockImages | boolean | false | Never send images to providers. |
tui.hyperlinks | enum | auto | off, auto, always. |
tui.scrollIsolation | boolean | false | Mouse wheel scrolls the transcript while the prompt stays pinned at the bottom of the window, with the scroll position drawn on the right edge of the transcript (/settings → Appearance → Display, Advanced). Scrolling back reaches the whole session, not just what is on screen. Off by default: turning it on means veyyon holds the mouse to read wheel events, and your terminal’s own drag-to-select stops working while it does. With it on you select using shift+drag, or with /copy, which picks text or code from the conversation without the mouse. With it off the wheel drives the terminal’s native scrollback, the whole window scrolls with it including the prompt, and selection behaves as it does in any other program. |
For a custom status line, set statusLine.preset: custom and configure statusLine.leftSegments, statusLine.rightSegments, and statusLine.segmentOptions. See the status line reference for the full list of segment IDs.
One segment is worth calling out: profile shows the active profile name (work, rec, a client sandbox) so you always know which profile’s config, sessions, and keys are live. It hides on the built-in default profile, so a vanilla status line is unchanged, and every built-in preset already includes it.
Interaction
| Key | Type | Default | Values |
|---|---|---|---|
steeringMode | enum | one-at-a-time | all, one-at-a-time. How queued steering messages are delivered. |
followUpMode | enum | one-at-a-time | all, one-at-a-time. |
interruptMode | enum | immediate | immediate, wait. |
doubleEscapeAction | enum | tree | branch, tree, none. |
autoResume | boolean | false | Auto-resume the most recent session in the cwd. |
ask.timeout | number | 0 | Seconds before an ask prompt times out; 0 = no timeout. Values above 1000 are read as milliseconds from an older config and divided by 1000, so 1000 seconds is the longest timeout you can set. A rewrite is reported in the log with both values. |
ask.notify | enum | on | on, off. |
session.workdir | string | unset | Per-profile default working directory. When you launch without an explicit --cwd, the session starts here. Precedence: an explicit --cwd wins, then this setting, then the directory you launched from. Use an absolute or ~-relative path; a relative path or a missing directory makes launch fail loudly (no silent fallback). Set it in /settings (Interaction tab, Profile group, “Default Working Directory”) or with veyyon config set session.workdir /path/to/project; clear it with veyyon config set session.workdir "". This is a per-profile default that persists across sessions. It is distinct from /cwd set (and the agent’s set_cwd tool), which re-root the live working directory for the current session only and write nothing to your profile. Note: if you launch from your bare home directory with no --cwd, no --allow-home, and this setting unset, veyyon relocates the session to a scratch directory (~/tmp, then /tmp) and prints a one-line notice saying so; set session.workdir to a real project directory to land there instead. |
Providers and services
providers:
webSearch: auto
image: auto
fetch: auto
webSearchGeminiModel: gemini-2.5-flash
tinyModel: online
tinyModelDevice: default
tinyModelDtype: default
openaiWebsockets: auto
openrouterVariant: default
kimiApiFormat: anthropic
provider:
appendOnlyContext: auto # auto, on, off
exa:
enabled: true
enableSearch: true
enableResearcher: false
enableWebsets: false
searxng:
endpoint: https://search.example.com
token: SEARXNG_TOKEN
| Key | Type | Default | Values / notes |
|---|---|---|---|
providers.webSearch | enum | auto | auto plus the configured search providers (perplexity, gemini, anthropic, codex, xai, zai, exa, tinyfish, jina, kagi, tavily, firecrawl, brave, kimi, parallel, synthetic, searxng, startpage, duckduckgo, google, mojeek, public). |
providers.webSearchGeminiModel | string | (unset) | Gemini model ID for Google Search grounding when web_search uses Gemini; defaults to gemini-2.5-flash, overridden by GEMINI_SEARCH_MODEL. |
providers.image | enum | auto | auto, openai, antigravity, xai, gemini, openrouter. |
providers.fetch | enum | auto | auto, native, trafilatura, lynx, parallel, jina. |
providers.tinyModel | enum | online | online or a local model (lfm2-350m, qwen3-0.6b, gemma-270m, qwen2.5-0.5b, lfm2-700m). |
providers.tinyModelDevice | enum | default | ONNX execution provider for local tiny models. Overridden by VEYYON_TINY_DEVICE. |
providers.tinyModelDtype | enum | default | ONNX precision for local tiny models. Overridden by VEYYON_TINY_DTYPE. |
providers.openaiWebsockets | enum | auto | auto, off, on. |
providers.openrouterVariant | enum | default | default, nitro, floor, online, exacto. |
providers.kimiApiFormat | enum | anthropic | openai, anthropic. |
provider.appendOnlyContext | enum | auto | auto, on, off. |
exa.enabled | boolean | true | Enable Exa integration. |
exa.enableSearch | boolean | true | Exa search. |
exa.enableResearcher | boolean | false | Exa researcher. |
exa.enableWebsets | boolean | false | Exa websets. |
searxng.endpoint | string | (unset) | SearXNG instance URL. |
searxng.token | string | (unset) | SearXNG token; also searxng.basicUsername/searxng.basicPassword/searxng.categories/searxng.language. |
The auth-broker keys (auth.broker.url / auth.broker.token) live in the machine-wide global config, not a profile’s own file; see Global (all profiles).
Provider credentials and custom model definitions are configured separately, see Providers and Models.
Global (all profiles)
These keys live in the machine-wide ~/.veyyon/config.yml, not a profile’s own config, and are edited on the Global tab of /settings. They are read live, so an external edit to that file takes effect without a restart.
Two of these have a different name depending on how you reach them: config set and /settings take the schema path, and the value is stored under a nested key in the file. Both names are given below.
Setting key (config set) | Stored as | Type | Default | Values / notes |
|---|---|---|---|---|
defaultProfile | defaultProfile | string | default | Which profile a bare vey launches when --profile and VEYYON_PROFILE are unset. Also settable with veyyon profile default [name]; setting it back to default clears the override. |
profileSharing | profileSharing | boolean | true | When true, every profile reads one machine-wide provider credential store (~/.veyyon/shared-auth/agent.db). Set false to give each profile its own private credentials. See Providers. |
authBrokerUrl | auth: { broker: { url } } | string | (empty) | Auth-broker base URL, shown as Auth Broker URL on the Global tab. The legacy flat "auth.broker.url" key is still read and is rewritten to the nested form on the next save. VEYYON_AUTH_BROKER_URL still wins over config. |
authBrokerToken | auth: { broker: { token } } | string | (empty) | Auth-broker bearer token, shown as Auth Broker Token. Write-only in /settings: a stored token renders as a mask and is never echoed; enter a new value to replace it, leave the mask to keep it, or clear the field to delete it. VEYYON_AUTH_BROKER_TOKEN still wins over config. |
Every other setting
The sections above are the settings worth explaining at length. For the complete list, see the settings reference: every setting that appears in /settings, with its key, type, default, and what it does, grouped exactly as the tabs are, followed by every key that exists only in a configuration file. That page is generated from the schema, so it cannot fall behind the code; the narrative here is the part written by hand.
veyyon config list shows the same set with your current values.
Legacy migration
veyyon migrates older config shapes automatically. None of these require action; they are listed so you know what changes you may see in config.yml.
Startup migration to config.yml
When ~/.veyyon/profiles/default/agent/config.yml does not exist, startup builds it once from legacy sources, then writes the result:
~/.veyyon/profiles/default/agent/settings.json(renamed tosettings.json.bakafter a successful migration).- Settings persisted in
agent.db.
After config.yml exists, these legacy sources are no longer consulted. The generic config loader also performs .json -> .yml migration for other config files when only the .json form is present.
Field-level migrations
Applied whenever raw settings are loaded (profile config, --config overlays, and runtime overrides):
| Old | New |
|---|---|
queueMode | steeringMode |
ask.timeout in milliseconds (value > 1000) | seconds (divided by 1000), and the rewrite is logged with both values |
flat theme: "<name>" string | theme.dark / theme.light (slot chosen by luminance; built-in light/dark are dropped to use defaults) |
task.isolation.enabled: true/false | subagent.isolation.mode: auto/none |
task.simple | removed |
legacy task.isolation.mode (worktree, fuse-overlay, fuse-projfs) | rcopy, overlayfs, projfs |
task.eager (default / preferred / always, or a boolean) | subagent.delegation (allowed / preferred / required) |
task.batch, task.maxConcurrency, task.maxRecursionDepth, task.maxRuntimeMs, task.softRequestBudget, task.softRequestBudgetNotice, task.showResolvedModelBadge, task.enableLsp | the same names under subagent. |
task.agentIdleTtlMs | subagent.idleTtlMs |
task.isolation.* | subagent.isolation.* |
task.disabledAgents | one row per agent in subagent.agents |
task.agentModelOverrides | dropped, and each override is named in the log. Per-agent models no longer exist: subagent.model (with subagent.thinkingLevel) is the one owner, and an agent that needs its own model declares it in its own model: frontmatter. A subagent.agents.<name>.model or .thinkingLevel left in a config is ignored and reported the same way. |
modelRoles.task | subagent.model (the task role is retired) |
lastChangelogVersion | moved to a marker file and stripped from config.yml |
collapseChangelog | removed; startup no longer prints release notes, so there is nothing to collapse. Use startup.updateNotice to control the one-line notice that replaced it. |
Troubleshooting
A .veyyon/config.yml in a repository is ignored
That is the rule, not a malfunction: a working tree never configures the agent, so a checked-in settings file is not read. Move the values into your profile config, pass them for one run with --config <file>, or use a path-scoped array for enabledModels / disabledProviders.
An array from my profile disappeared under an overlay
Arrays replace; they do not append. If an overlay sets disabledProviders, enabledModels, cycleOrder, extensions, or any other array, include the complete desired value in the overlay, the profile array is fully replaced.
A provider is still available after editing config
- Check whether you disabled the model provider id (e.g.
anthropic) or a discovery source id (e.g.claude): they are different namespaces with different effects. - Check for an overlay
disabledProvidersarray replacing your profile one. - Credentials can still come from environment variables,
.env, OAuth, stored auth, ormodels.yml; disabling a provider blocks selection regardless, but verify you edited the right layer. See Providers. - Restart the session if the model list was already initialized.
veyyon config set changed the wrong file
veyyon config set and veyyon config reset always write the config.yml under the active agent directory. Run veyyon config path to print it.
veyyon config reset removed my global override
That is what reset does: it deletes the key from the profile config.yml so the schema default (or an overlay or runtime value) applies. To keep a custom value, run veyyon config set <key> <value> again.
A --config overlay fails at startup
--config files are process-local YAML mappings. A missing file, invalid YAML, or a top-level array/scalar is a hard error, it does not silently fall back to lower-precedence settings. Fix the path or contents.
An environment variable beats my config
Some settings (model roles, eval backends, tiny-model device/precision, auth broker, PTY) are overridable by env vars or CLI flags for per-machine convenience, and those take precedence over config.yml. Unset the variable or drop the flag to let the persisted value win. See Environment overrides and Environment variables.
veyyon config set <key> says “Unknown setting”
Keys must match a schema path exactly, with no shorthand. Use theme.dark, not theme. Run veyyon config list to see every valid key.
Settings reference
Every setting the schema declares: the ones /settings shows, grouped as that screen groups them, then the ones that exist only in a configuration file.
Generated by scripts/gen-settings-reference.ts. Edit the ui block on the setting in packages/coding-agent/src/config/settings-domains/, then run bun scripts/gen-settings-reference.ts --write. Never edit this file by hand: a test compares it against the generator and fails when the two disagree.
Read Settings first for where settings live, how precedence and merging work, and how to read and write them. The tables below are grouped as the /settings tabs are.
Set any of these keys in config.yml with the dotted path shown in the first column, or from the command line:
veyyon config set tui.tight true
veyyon config get compaction.threshold
Appearance
Theme
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
theme.dark | Dark Theme | string | titanium | Theme used when the terminal has a dark background. |
theme.light | Light Theme | string | light | Theme used when the terminal has a light background. |
symbolPreset | Symbol Preset | enum | unicode | Glyph set for icons and symbols (Unicode, Nerd Font, or ASCII). Values: unicode, nerd, ascii. |
colorBlindMode | Color-Blind Mode | boolean | false | Use blue instead of green for diff additions. |
Status Line
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
statusLine.enabled | Composer Footline | boolean | true | Show the quiet metadata line under the composer (model, mode, path, git, context). Off leaves the composer carrying nothing; the agent-focus exit hint still shows while a view is proxied. |
statusLine.preset | Status Line Preset | enum | default | Pre-built status line configurations. Values: default, minimal, compact, full, nerd, ascii, custom. |
statusLine.sessionAccent | Session Accent | boolean | true | Use the session name color for the editor border. Shown under the tab’s Advanced fold. |
statusLine.compactThinkingLevel | Compact Thinking Level | boolean | false | Show the thinking level as a single icon on the model name instead of a separate · \<level> suffix. Shown under the tab’s Advanced fold. |
statusLine.showHookStatus | Show Hook Status | boolean | true | Display hook status messages below the status line. Shown under the tab’s Advanced fold. |
statusLine.showAccount | Show Serving Account | boolean | false | Name the account serving the next request on the composer footline, when the active provider stores more than one. Off: /account answers it on demand. Shown under the tab’s Advanced fold. |
Display
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
terminal.showImages | Show Inline Images | boolean | true | Render images inline in the terminal. |
images.autoResize | Auto-Resize Images | boolean | true | Resize large images to 2000x2000 max for better model compatibility. Shown under the tab’s Advanced fold. |
terminal.showProgress | Native Terminal Progress | boolean | false | Emit OSC 9;4 indeterminate progress while the agent or context maintenance is running. Shown under the tab’s Advanced fold. |
tui.textSizing | Large Headings (Kitty) | boolean | false | Render Markdown H1 headings at 2x scale using Kitty’s OSC 66 text-sizing protocol. Only takes effect on Kitty terminals; ignored everywhere else. Off by default. Shown under the tab’s Advanced fold. |
tui.renderMermaid | Render Mermaid Diagrams | boolean | true | Render Mermaid fenced code blocks as ASCII diagrams. Shown under the tab’s Advanced fold. |
tui.hyperlinks | Terminal Hyperlinks | enum | auto | Wrap paths and URLs in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional). Values: off, auto, always. |
tui.paintGround | Paint Theme Ground | enum | auto | Set the terminal background (OSC 11) to the theme’s ground color while Veyyon runs, restoring it on exit (auto: only when the terminal background already matches the theme so no seam appears; always: unconditional; never: inherit the terminal background). Values: auto, always, never. |
tui.tight | Tight Layout | boolean | false | Remove the 1-character horizontal padding from the left and right of the terminal output. Shown under the tab’s Advanced fold. |
tui.scrollbackRebuild | Rewrite Scrollback | boolean | true | Erase and replay terminal scrollback when a block’s final form replaces its live preview. On by default: with it off, the stale preview stays in history and the final content is appended underneath, so the same paragraph appears twice. Terminal multiplexers keep the append-below behaviour either way, because erasing there would take the pane’s own history with it. Shown under the tab’s Advanced fold. |
tui.scrollIsolation | Scroll Isolation | boolean | false | Read the mouse wheel so the transcript scrolls with the prompt pinned at the bottom, showing the position on the right edge. This costs you drag-select: while it is on, veyyon holds the mouse, so plain dragging selects nothing and you need shift+drag, or /copy to pick text and code out of the conversation without the mouse. When off (default), the terminal keeps the wheel and the mouse, so native scrollback, drag-select and copy all behave exactly as they do in any other program, and the prompt still sits at the bottom of the live view. Shown under the tab’s Advanced fold. |
display.transitions | Transitions | enum | on | Structural motion: overlay open transitions and the moving rail beside a running tool. Values: on, off. |
display.shimmer | Shimmer | enum | disabled | Animation style for working/loading messages. Values: classic, kitt, living, disabled. |
display.smoothStreaming | Smooth Streaming | boolean | true | Reveal assistant text and streamed tool input smoothly while chunks arrive. |
display.showTokenUsage | Show Token Usage | boolean | false | Show what each turn spent under the assistant message: tokens, how long it took, and its rate. |
display.cacheMissMarker | Cache Miss Marker | boolean | false | Show a divider above an assistant turn whose request lost (missed) the prompt cache. Shown under the tab’s Advanced fold. |
display.toolOutputExpanded | Expand Tool Output | boolean | false | Start tool calls expanded, showing full input and output instead of a preview; the in-session toggle updates this. Shown under the tab’s Advanced fold. |
showHardwareCursor | Show Hardware Cursor | boolean | true | Show terminal cursor for IME support. Shown under the tab’s Advanced fold. |
Model
Compaction
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
display.collapseCompacted | Collapse Compacted History | boolean | true | Collapse pre-compaction history behind the summary divider on the live transcript; disable to keep the full transcript inline with dividers at each compaction point. |
compaction.remote | Remote Compaction | boolean | true | Applies only when the session model is a supported OpenAI Responses model, which includes Azure OpenAI Responses deployments and ChatGPT Codex sessions; every other model ignores this setting and compacts locally. On, veyyon has the provider compact the span and keeps the window it returns, which preserves reasoning state across the cut. That window is the whole compacted context, so the entry stores no summary text and the compaction model chain does not apply. There is no second local summary on purpose: it would pay a model to re-summarize a span the provider already compacted and leave two versions of one range that can disagree. Off, compaction runs locally on the usual summary path and stores readable summary text. |
compaction.strategy | Compaction Type | enum | summary | Summary condenses history in place and continues the same session. Values: summary. |
compaction.threshold | Auto-Compaction Threshold | string | auto | When auto-compaction triggers. Auto uses the model’s window minus the reserve; a percent scales with each model’s window; a token amount is the same trigger on every model that can reach it, and a smaller model compacts at its own maximum. |
compaction.model | Compaction Model | modelChain | (unset) | Models used for in-place summary compaction, tried in order. Default: inherit — follows the main model live. Add fallbacks for when the first is unauthenticated or its window is too small. |
compaction.modelFallbackStrategy | Compaction Fallback | enum | auto | What to try after the compaction models you configured. Auto stays on models you named: the main model, its same-provider compaction sibling, and your model roles. Any authenticated model also reaches the largest window available, on any provider you have credentials for. Configured only stops at the chain and fails loudly. Values: auto, any-model, configured-only. |
compaction.modelContextWindow | Compaction Model Context | number | (unset) | Context window in tokens to assume for the compaction model. Unset uses the compaction model’s own reported window. Candidates whose window cannot fit the summarization payload are skipped loudly. |
Roles
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
modelRoles | Role Models | record | {} | Assign a model to each role (Fast, Thinking, Vision, Architect, Designer, Commit, Tiny). Opens a searchable picker with auth status. The advisor’s model is asked for in the Advisor group, and a subagent’s in Subagents → Roster, so neither appears here. Scoped to the active profile — never edit config by hand. |
Thinking
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
defaultEffort | Default Effort | record | {} | Effort per model, applied when a run does not ask for one. Add a model and pick its effort; the “any model” row covers every model without its own. Per profile. |
hideThinkingBlock | Hide Thinking Blocks | boolean | false | Hide thinking blocks in assistant responses. |
proseOnlyThinking | Prose Only Thinking | boolean | true | Omit code blocks from thinking summaries and replace them with an ellipsis. |
omitThinking | Omit Thinking summaries | boolean | false | Instruct upstream providers to completely omit thinking summaries from responses (where supported). |
model.loopGuard.enabled | Loop Guard | boolean | true | Enable automatic stream loop detection for model reasoning and prose. |
model.loopGuard.checkAssistantContent | Loop Guard Scan Prose | boolean | true | Apply loop guard to assistant prose messages in addition to thinking logs. |
model.loopGuard.toolCallReminder | Loop Guard Tool-Call Reminder | boolean | true | When a Gemini reasoning stream emits many consecutive planning headers without calling a tool, interrupt it and inject a reminder to issue a tool call (requires Loop Guard). |
model.toolCallLoopGuard.enabled | Tool-Call Loop Guard | boolean | true | Detect consecutive identical tool calls across turns and inject a corrective steer. |
model.toolCallLoopGuard.threshold | Tool-Call Loop Threshold | number | 5 | Consecutive identical tool calls required before the corrective steer is injected. |
model.toolCallLoopGuard.readSubsumptionThreshold | Read Subsumption Loop Threshold | number | 3 | Consecutive fully-subsumed or redundant read calls on unchanged files before the corrective steer is injected. |
model.toolCallLoopGuard.exemptTools | Tool-Call Loop Exempt Tools | array | ["job","irc"] | Tool names that may repeat consecutively without triggering the cross-turn loop guard. |
providers.autoThinkingModel | Auto Thinking Model | enum | online | Difficulty classifier for the auto thinking level: online (the TINY role from /models, else smol) by default, or a local on-device model. Values: online, qwen3-1.7b, llama3.2:3b, gemma-3-1b, qwen2.5-1.5b, lfm2-1.2b. |
Sampling
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
temperature | Temperature | number | (unset) | Sampling temperature. 0 is deterministic, 1 is creative. Unset lets the provider choose. |
topP | Top P | number | (unset) | Nucleus sampling cutoff, 0 to 1. Unset lets the provider choose. |
topK | Top K | number | (unset) | Sample from the top K tokens. Unset lets the provider choose. |
minP | Min P | number | (unset) | Minimum probability threshold, 0 to 1. Unset lets the provider choose. |
presencePenalty | Presence Penalty | number | (unset) | Penalty for introducing tokens already present. Negative values encourage repetition; unset lets the provider choose. |
repetitionPenalty | Repetition Penalty | number | (unset) | Penalty for repeated tokens. Values below 1 encourage repetition; unset lets the provider choose. |
textVerbosity | Text Verbosity | enum | medium | OpenAI Responses and Codex response verbosity (low, medium, or high). Values: low, medium, high. |
tier.openai | Service Tier — OpenAI | enum | none | How your OpenAI / OpenAI-Codex requests are queued and served, including OpenAI-family models routed via OpenRouter (none = omit the field). Sent as service_tier. This is serving speed and cost, not reasoning depth; depth is Default Effort. Values: none, auto, default, flex, scale, priority. |
tier.anthropic | Service Tier — Anthropic | enum | none | How your Claude requests are queued and served. priority realizes fast mode (speed: "fast") on supported direct Anthropic models, and is ignored on Bedrock/Vertex Claude and via OpenRouter. This is serving speed and cost, not reasoning depth; depth is Default Effort. Values: none, priority. |
tier.google | Service Tier — Google | enum | none | How your Gemini (Google AI Studio + Vertex) requests are queued and served, including Google-family models routed via OpenRouter (none = omit the field). Sent as the top-level serviceTier field. This is serving speed and cost, not reasoning depth; depth is Default Effort. Values: none, flex, priority. |
tier.subagent | Service Tier — Subagent | enum | inherit | How spawned task/eval subagent requests are queued and served. Inherit matches the main agent’s live per-family tiers (tracks /fast); pick a value to apply it to whichever family the subagent’s model belongs to. Values: inherit, none, auto, default, flex, scale, priority. |
tier.advisor | Service Tier — Advisor | enum | none | How advisor-model requests are queued and served. None is standard processing, Inherit matches the main agent’s live per-family tiers, and picking a value applies it to the advisor model’s family. Values: inherit, none, auto, default, flex, scale, priority. |
Prompt
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
inlineToolDescriptors | Inline Tool Descriptors | enum | auto | Render full tool descriptors in the system prompt and strip top-level/nested descriptions from provider tool schemas so descriptor text is sent once. Auto follows the active model, enabling this for Gemini and disabling it otherwise. Values: auto, on, off. |
includeModelInPrompt | Include Model in Prompt | boolean | false | Surface the active model identifier in the system prompt so the agent knows which model it is. Costs a full prompt-cache invalidation on every model switch. |
includeWorkspaceTree | Include Workspace Tree | boolean | false | Render the workspace directory tree in the system prompt. WARNING: This can bust prompt caching across sessions when files are modified. |
personality | Personality | string | default | Communication style rendered into the system prompt’s personality block. Extend via ~/.veyyon/personalities/<name>.md or project .veyyon/personalities/<name>.md. |
Retry & Fallback
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
retry.maxRetries | Retry Attempts | number | 10 | Maximum retry attempts on API errors. |
retry.maxDelayMs | Max Retry Delay | number | 300000 | Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows). |
retry.modelFallback | Retry Model Fallback | boolean | true | Allow retry recovery to switch to configured fallback models. |
retry.fallbackChains | Retry Fallback Chains | record | {} | JSON object mapping model roles, model selectors (“provider/model-id”), or provider wildcards (“provider/”) to ordered fallback selectors, e.g. {“default”:[“openai/gpt-4o-mini”],“google-antigravity/”:[“google/”,“google-vertex/”]}. Model-oriented keys apply whenever that model/provider is active, regardless of role; a “provider/*” entry keeps the failing model’s id and swaps the provider. |
retry.perProvider | Per-Provider Retry | record | {} | JSON object overriding retry limits for specific backends, keyed like Retry Fallback Chains: a model selector (“provider/model-id”), a provider wildcard (“provider/*”), or a bare provider name. Each value may set maxRetries, baseDelayMs, and maxDelayMs; anything omitted falls back to the global retry settings. Example: {“cursor”:{“maxRetries”:3,“baseDelayMs”:2000}}. Backends whose retries are intrinsically expensive (cursor, devin) already ship with sensible limits; an entry here overrides those. |
retry.fallbackRevertPolicy | Fallback Revert Policy | enum | cooldown-expiry | When to return to the primary model after a fallback. Values: cooldown-expiry, never. |
providers.anthropic.serverSideFallback | Anthropic Server-Side Fallback (Fable 5) | boolean | false | When a Claude Fable 5 / Mythos 5 request is blocked by Anthropic’s safety classifier, retry it on Claude Opus 4.8 server-side (Anthropic server-side-fallback-2026-06-01 beta). Opt-in — leaving this off preserves the pre-fallback behavior for every request. |
Advisor
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
advisor.enabled | Enable Advisor | boolean | false | Pair a second model that passively reviews each turn and injects notes. Which model it runs is Advisor Model, directly below. |
advisor.subagents | Advisor for Subagents | boolean | false | Also enable the advisor on spawned task/eval subagents. |
advisor.syncBacklog | Advisor Sync Backlog | enum | off | Pause the main agent for up to 30 seconds if the advisor falls behind by this many turns. Off disables catch-up delays. Values: off, 1, 3, 5. |
advisor.immuneTurns | Advisor Immune Turns | number | 3 | After an advisor concern or blocker interrupts, route further concerns/blockers non-interruptingly for this many primary turns. |
Prewalk
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
prewalk.enabled | Enable Prewalk | boolean | false | Plan on the strong model, then switch to the cheap model at the first edit/write after the plan nudge’s todo list exists — the strong model commits the todos and starts the implementation before handing off. The cheap model comes from Prewalk Cheap Model; Prewalk Strong Model overrides the start model. Overridable per session with –prewalk / –no-prewalk. |
prewalk.cheapModel | Prewalk Cheap Model | modelChain | (unset) | Model prewalk hands off to at the first edit/write. Required once prewalk is on: /prewalk and –prewalk fail with a message naming this setting when it is unset. –prewalk-into overrides it per session; only the first entry is used. |
prewalk.strongModel | Prewalk Strong Model | modelChain | (unset) | Model a prewalk session starts on — the strong model that plans before the handoff. Unset: inherit the normal start model (–model or the remembered default). Only the first entry is used. |
Vision
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
images.describeForTextModels | Describe Images for Text Models | boolean | true | When an image is attached to a model without vision support, save it under local:// and inject a description from a vision-capable model instead of dropping it. |
Interaction
Input
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
steeringMode | Steering Mode | enum | one-at-a-time | How to process queued messages while agent is working. Values: all, one-at-a-time. |
followUpMode | Follow-Up Mode | enum | one-at-a-time | How to drain follow-up messages after a turn completes. Values: all, one-at-a-time. |
interruptMode | Interrupt Mode | enum | immediate | When steering messages interrupt tool execution. Values: immediate, wait. |
loop.mode | Loop Mode | enum | prompt | What happens between /loop iterations before re-submitting the prompt. Values: prompt, compact, reset. |
doubleEscapeAction | Double-Escape Action | enum | tree | Action when pressing Escape twice with empty editor. Values: branch, tree, none. |
treeFilterMode | Session Tree Filter | enum | default | Default filter mode when opening the session tree. Values: default, no-tools, user-only, labeled-only, all. |
autocompleteMaxVisible | Autocomplete Items | number | 5 | Max visible items in autocomplete dropdown (3-20). |
emojiAutocomplete | Emoji Autocomplete | boolean | true | Suggest emojis from :name: shortcodes and expand text emoticons like :D or :-). |
paste.largeMenuThreshold | Large Paste Menu | number | 100 | When a paste reaches this many lines, offer a menu to wrap it in a code block, wrap it in XML tags, or save it to a file. 0 disables the menu (large pastes still collapse to a [Paste] marker). |
Session
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
session.newKeepsBackground | /new Keeps The Old Session | boolean | false | Requires a restart: switching this on or off changes nothing in the running session. On /new while a response is still streaming, keep the old conversation running in the background and attach the screen to a fresh one. The status line counts running background conversations. Off stops the old turn and closes its provider stream before the new session starts, so nothing keeps billing once it leaves the screen. |
Approvals
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
tools.approval | Tool Approval Policies | record | {} | Per-tool approval policies. Set to ‘allow’ to auto-approve, ‘prompt’ to require confirmation, or ‘deny’ to block. Overrides are honored in every approval mode. Any other value denies that tool and is reported at startup. |
tools.protectedPaths | Extra Protected Paths | array | [] | Additional absolute paths (a leading ~ is expanded) that a recursive delete must never target without approval. Adds to the built-in set; it cannot remove from it. |
tools.approvalMode | Tool Approval | enum | auto | How much the agent may do without asking. Defaults to Auto: every tier runs, with the per-tool policies, working-directory boundary, credential and critical-call guards still asking. This is the persisted default; override it for one session with /permissions. Values: plan, ask, ask-command, auto, yolo, always-ask, write, auto-edit. |
Notifications
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
completion.notify | Completion Notification | enum | off | Notify when the agent finishes a turn (off by default: the turn is on your screen). Values: on, off. |
ask.timeout | Ask Timeout | number | 0 | Auto-select the recommended ask option after this many seconds (0 disables). |
ask.notify | Ask Notification | enum | on | Notify when the agent is blocked on a question you have not answered. Values: on, off. |
recap.enabled | Idle Recap | boolean | true | Generate a brief LLM recap of where things stand after the terminal has been idle. |
recap.idleSeconds | Idle Recap Delay | number | 240 | Seconds to wait while idle before showing the recap. |
Speech
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
stt.enabled | Speech-to-Text | boolean | false | Enable speech-to-text input via microphone. |
stt.modelName | Speech Model | enum | parakeet | Local on-device speech model. Parakeet TDT v3 (sherpa-onnx) is the SoTA default; Whisper base/small/large-v3-turbo tiers (transformers.js) trade size for multilingual coverage. Downloaded on first use. Values: fast, balanced, turbo, parakeet. |
stt.submitTrigger | Speech-to-Text Submit Trigger | enum | never | Choose when speech dictation automatically submits: Never, Release (2+ words), Release with complete sentence, or When I Say Submit. Values: never, release, release-complete, say-submit. |
Collab
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
collab.relayUrl | Relay URL | string | wss://share.veyyon.dev | Relay used by /collab (wss://host[:port]). |
collab.webUrl | Web UI URL | string | (empty) | Browser UI used by /collab links; empty derives from collab.relayUrl; explicit http:// is localhost-only. |
collab.displayName | Display Name | string | (empty) | Name shown to other collab participants (default: OS username). |
share.serverUrl | Share Server | string | https://share.veyyon.dev/s | Share viewer/upload base used by /share (encrypted blob upload + viewer; links are <base>/<id>#<key>). |
share.store | Share Store | enum | blob | Where /share uploads the encrypted session blob. Values: blob, gist. |
share.redactSecrets | Share Secret Redaction | boolean | true | Run the secret obfuscator over /share snapshots before upload (uses the secrets.* config). |
Magic Keywords
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
magicKeywords.enabled | Enable Magic Keywords | boolean | true | Enable hidden notices for standalone ultrathink, orchestratez, and workflowz keywords. |
magicKeywords.ultrathink | Ultrathink Keyword | boolean | true | Let standalone ultrathink request maximum automatic thinking and append its hidden notice. |
magicKeywords.orchestrate | Orchestrate Keyword | boolean | true | Let standalone orchestratez append its hidden multi-agent orchestration notice. |
magicKeywords.workflow | Workflow Keyword | boolean | true | Let standalone workflowz append its hidden eval workflow notice. |
magicKeywords.turnBudget | Turn Budget Directive | boolean | false | Let a standalone +500k or +2m set this turn’s output-token budget; when off, +Nk in a message is treated as ordinary text. |
Startup & Updates
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
autoResume | Auto Resume | boolean | false | Automatically resume the most recent session in the current directory. |
startup.quiet | Quiet Startup | boolean | false | Skip welcome screen and startup status messages. |
startup.showSplash | Show Startup Splash | boolean | false | Show the full animated setup splash on normal interactive startup without rerunning setup. Quiet Startup still suppresses it. |
startup.clearScrollback | Clear Scrollback on Startup | boolean | false | Erase the terminal’s saved scrollback when veyyon starts, so the session begins on an empty terminal. This also erases what was on screen before you launched, such as your shell history and any command output, and it cannot be undone. Off still starts you on a clear screen; it just leaves your history reachable by scrolling up. |
startup.setupWizard | Setup Wizard | boolean | true | Run onboarding on first install only (updates never re-run it). |
startup.checkUpdate | Check for Updates | boolean | true | Check for Veyyon updates on startup. |
marketplace.autoUpdate | Marketplace Auto-Update | enum | notify | Check for plugin updates on startup. Values: off, notify, auto. |
startup.autoUpdate | Automatic Updates | boolean | true | Install a newer version in the background; off means updates only when you run veyyon update. |
startup.updateNotice | Update Notice | boolean | true | Show a one-line notice on the first launch after an update. |
Profile
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
profile.displayName | Profile Name | string | (empty) | Display name for the active profile, shown in /profile list and resolvable by /profile <name>. Stored per profile; empty falls back to the profile’s directory name (“default” for the base profile). |
session.workdir | Default Working Directory | string | (unset) | Per-profile default session working directory used when launching without an explicit –cwd. Precedence: an explicit –cwd wins, then this setting, then the directory you launched from. Use an absolute or ~-relative path; a relative path or a missing directory makes launch fail loudly. The agent can override the live session cwd for that session only via set_cwd / /cwd without writing this setting. |
Power (macOS)
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
power.sleepPrevention | Sleep Prevention | enum | idle | Prevent macOS sleep during active sessions. Each level is cumulative — it adds the flags of all lower levels. Values: off, idle, display, system. |
Agent
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
features.unexpectedStopDetection | Detect unexpected stops | boolean | false | Use a small model to detect when the assistant says it will continue but stops without tool calls; automatically prompt it to continue. |
Git
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
git.enabled | Enable Git Integration | boolean | true | Show git branch, status, and PR information in the TUI and watch repository metadata. |
Resources
CPU
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
session.cpuLimitCores | Session CPU Limit | number | 0 | Maximum CPU a session’s spawned processes may use, in cores (0 = off). This is the per-profile default: every session that profile starts inherits it, and one session can depart from it with /cpu-limit <cores> or lift it entirely with /cpu-limit remove, neither of which writes this setting. Every process the session starts (bash commands, MCP servers, custom tools, launch tasks, workers) joins a per-session budget group: a cgroup v2 quota on Linux, a Job Object hard cap on Windows, both kernel-enforced, so the group throttles as a whole. While the group runs saturated, new commands are refused with an error naming the budget. On macOS there is no kernel quota, so enforcement is policy-only (refuse new commands, renice, optional kill) and a startup warning says so. The harness’s own compute (agent turns, in-process workers) is never capped. |
session.cpuLimitKill | Kill Over-Budget Commands | boolean | false | What happens when spawned commands stay at the CPU limit for seconds at a time. Off (default): new commands are refused until usage drops, running ones keep running (throttled where the OS offers a quota, reniced on macOS). On: the over-budget group is also sent SIGTERM, and the kill is reported as a budget action, not a crash. /cpu-limit kill on|off changes it for one session without writing this setting. |
Memory
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
session.memoryLimitGb | Session Memory Limit | number | 0 | Maximum resident memory the session tree may hold at once, in gigabytes (0 = off). The session tree is this session, every subagent under it at any depth, and every process any of them spawned: they share one budget group, so delegating work cannot multiply the allowance. This is a kernel cap, not a polite refusal: on Linux it is cgroup v2 memory.max on the session budget group, so a group at the limit is reclaimed first and then a process INSIDE it is OOM-killed by the kernel, whichever process the kernel picks, with no warning and no chance to finish. Set it where an OOM kill is preferable to the machine swapping, and leave it off if a killed command would cost more than the memory does. A host without a memory controller reports the limit as unenforceable once at startup rather than pretending to hold it. |
Disk
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
session.writeBudgetGb | Session Write Budget | number | 0 | Cumulative gigabytes the session tree may WRITE to disk before further writes are refused (0 = off). The session tree is this session, every subagent under it at any depth, and every process any of them spawned: they share one budget group, so delegating work cannot multiply the allowance. Writes are metered by the same group that meters CPU (cgroup v2 io accounting on Linux, Job Object I/O accounting on Windows). Once the total is reached, a new command is refused with an error naming the budget and how much it has written; already running commands keep running unless Kill Over-Budget Writers is on. A host where write accounting cannot be read reports the limit as unenforceable once at startup rather than pretending to hold it. |
session.writeBudgetKill | Kill Over-Budget Writers | boolean | false | What happens when the session tree passes its write budget. Off (default): new commands are refused, and whatever is already writing runs to completion. On: the over-budget group is also sent SIGTERM, and the kill is reported as a budget action rather than a crash, so a command that vanished mid-write is explained instead of looking like a failure. Hidden while the write budget is 0, because a kill policy for a budget that does not exist is a knob with nothing behind it. |
Processes
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
session.maxProcesses | Session Max Processes | number | 0 | Hard cap on how many processes may be alive at once across the session tree (0 = off). The session tree is this session, every subagent under it at any depth, and every process any of them spawned, all in one budget group, so the cap is not multiplied by delegating. Enforced by the kernel where it can be: cgroup v2 pids.max on Linux and a Job Object process limit on Windows both refuse the fork itself, so a runaway loop stops instead of filling the process table. Elsewhere the cap is policy-only, refusing a new spawn with an error naming the limit and the current count, and a startup notice says the kernel is not holding it. |
Context
General
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
contextPromotion.enabled | Auto-Promote Context | boolean | false | Promote to a larger-context model on context overflow instead of compacting. |
branchSummary.enabled | Branch Summaries | boolean | false | Prompt to summarize when leaving a branch. |
context.thinkingRetention | Thinking Retention | number | -1 | How many of the most recent assistant turns keep their unsigned thinking when the conversation is sent back. Gemini summarises its reasoning for you to read but replays the real reasoning from the signature on the tool call, so an old summary is transcript text the model re-reads and the provider ignores. Keep All resends every summary ever produced. Thinking that does carry a signature is always kept. Other providers ignore this. Shown under the tab’s Advanced fold. |
context.thoughtSignatureRetention | Thought Signature Retention | number | -1 | How many of the most recent assistant turns keep their Gemini thought signature when the conversation is sent back. Signatures let the model replay its own reasoning, and they are large, so the recent ones are the ones worth paying to resend. Keep All resends every signature ever produced, which on a long session is the single biggest thing in the context. Other providers ignore this. Shown under the tab’s Advanced fold. |
context.thoughtSignatureMaxLength | Thought Signature Size Limit | number | -1 | Longest Gemini thought signature still worth resending, in characters. Anything longer sends the skip sentinel instead, however recent it is. Signature sizes are lopsided: the largest tenth of them carry roughly two thirds of all signature bytes, so a limit sheds most of the weight while keeping the great majority of the reasoning chain. Use this instead of Thought Signature Retention when you want a gentler trade, or alongside it, in which case a signature is resent only if it is both recent enough and small enough. Other providers ignore this. Shown under the tab’s Advanced fold. |
Prompt Cache
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
cache.reportRejection | Report Cache Rejections | boolean | true | Warn when a turn asked the provider to cache a prefix and the provider cached nothing. Anthropic only; other providers do not report cache rejection. |
cache.blockOnRejection | Block On Cache Rejection | boolean | false | Anthropic only. Fail the next request after a rejected cache instead of continuing to pay full input rate. Off by default: the verdict is proven against provider usage reporting, so a provider that changes what it reports would stop the session rather than cost money. |
Session Instrumentation
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
session.instrumentation | Instrumentation Level | enum | off | Record structured, redacted study data in the session file. Higher levels add lifecycle, task-state, tool, model-turn, context, and agent-communication detail for veyyon session stats. Off still stores the normal resumable conversation and tool history, but adds no study fields. Values: off, basic, rich, ultra. |
Rules
Rules
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
ttsr.builtinRules | Built-in Rules | boolean | true | Load the default rules shipped with the agent. Turn individual rules off under All Rules. |
ttsr.disabledRules | All Rules | array | [] | Every rule this project loads, each on or off. Stores only the ones you turn off, so a rule added in a later release arrives on. |
Stream Interrupts (TTSR)
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
ttsr.enabled | TTSR | boolean | true | Interrupt the agent mid-stream when output matches rule patterns (Time-Traveling Stream Rules). |
ttsr.contextMode | Context Mode | enum | discard | What to do with partial output when TTSR triggers. Values: discard, keep. |
ttsr.interruptMode | Rule Interrupt Mode | enum | always | When to interrupt mid-stream vs inject warning after completion. Values: never, prose-only, tool-only, always. |
ttsr.repeatMode | Repeat Mode | enum | once | How rules can repeat: once per session or after a message gap. A rule may override this in its frontmatter. Values: once, after-gap. |
ttsr.repeatGap | Repeat Gap | number | 10 | Messages before a rule can trigger again. A rule may override this in its frontmatter. |
Memory
General
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
memory.backend | Memory Backend | enum | off | Off, local summary pipeline, Mnemopi SQLite, or Hindsight remote memory. Values: off, local, hindsight, mnemopi. |
providers.memoryModel | Memory Model | enum | online | Mnemopi LLM for fact extraction + consolidation: online (the TINY role from /models, else smol/remote) by default, or a local on-device model. Values: online, qwen3-1.7b, llama3.2:3b, gemma-3-1b, qwen2.5-1.5b, lfm2-1.2b. |
Mnemopi
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
mnemopi.dbPath | Mnemopi DB Path | string | (unset) | Optional SQLite DB path. Defaults to the agent memories directory. |
mnemopi.bank | Mnemopi Bank | string | (unset) | Optional shared bank base name. Per-project modes derive project-local banks from it. |
mnemopi.scoping | Mnemopi Scoping | enum | per-project | global = one shared bank; per-project = isolated bank per cwd; per-project-tagged = project-local writes plus global recall visibility. Values: global, per-project, per-project-tagged. |
mnemopi.embeddingVariant | Embedding variant | enum | en | Local embedding model family. en = stronger English model; multilingual = cross-language model. Changing this rebuilds existing memory embeddings on next start. Values: en, multilingual. |
mnemopi.autoRecall | Mnemopi Auto Recall | boolean | true | Recall local memories into the first turn of each session. |
mnemopi.autoRetain | Mnemopi Auto Retain | boolean | true | Retain completed conversation turns into local Mnemopi memory. |
mnemopi.polyphonicRecall | Mnemopi Polyphonic Recall | boolean | false | Enable 4-voice recall (vector, graph, fact, temporal) fused with reciprocal rank fusion. |
mnemopi.enhancedRecall | Mnemopi Enhanced Recall | boolean | false | Enable the tiered query result cache for repeated and similar recall queries. |
mnemopi.proactiveLinking | Mnemopi Proactive Linking | boolean | false | Ingest new memories into the episodic graph as they are stored, linking them to related entities and memories. |
mnemopi.noEmbeddings | Mnemopi Disable Embeddings | boolean | false | Force deterministic FTS-only recall instead of vector embeddings. |
mnemopi.embeddingModel | Mnemopi Embedding Model | string | (unset) | Advanced: explicit embedding model id that overrides the variant. Leave empty to use mnemopi.embeddingVariant. |
mnemopi.embeddingApiUrl | Mnemopi Embedding API URL | string | (unset) | Optional OpenAI-compatible embedding endpoint passed to Mnemopi. |
mnemopi.embeddingApiKey | Mnemopi Embedding API Key | string | (unset) | Optional embedding API key passed to Mnemopi. |
mnemopi.llmMode | Mnemopi LLM Mode | enum | smol | Use no LLM, the online tiny model (the TINY role from /models, else @smol), or a remote OpenAI-compatible endpoint. Values: none, smol, remote. |
mnemopi.llmBaseUrl | Mnemopi LLM Base URL | string | (unset) | Optional OpenAI-compatible LLM endpoint for Mnemopi remote mode. |
mnemopi.llmApiKey | Mnemopi LLM API Key | string | (unset) | Optional LLM API key for Mnemopi remote mode. |
mnemopi.llmModel | Mnemopi LLM Model | string | (unset) | Optional LLM model name for Mnemopi remote mode. |
Hindsight
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
hindsight.apiUrl | Hindsight API URL | string | http://localhost:8888 | Hindsight server URL (Cloud or self-hosted). |
hindsight.bankId | Hindsight Bank ID | string | (unset) | Base memory bank name. Unset uses veyyon. Hindsight Bank Prefix is prepended when set, and Hindsight Scoping decides whether the project name is appended (per-project) or carried as a project: tag instead (per-project-tagged). |
hindsight.scoping | Hindsight Scoping | enum | per-project-tagged | global = one shared bank; per-project = isolated bank per cwd; per-project-tagged = shared bank with project tags so global + project memories merge on recall. Values: global, per-project, per-project-tagged. |
hindsight.autoRecall | Hindsight Auto Recall | boolean | true | Recall memories on the first turn of each session. |
hindsight.autoRetain | Hindsight Auto Retain | boolean | true | Retain transcript every N turns and at session boundaries. |
hindsight.retainMode | Hindsight Retain Mode | enum | full-session | full-session = upsert one document per session, last-turn = chunked. Values: full-session, last-turn. |
hindsight.mentalModelsEnabled | Hindsight Mental Models | boolean | true | Read curated reflect summaries (mental models) into developer instructions at boot. Loads existing models on the bank — does not write. Pair with hindsight.mentalModelAutoSeed to also auto-create the built-in seed set. |
hindsight.mentalModelAutoSeed | Hindsight Mental Model Auto-Seed | boolean | true | At session start, create any built-in mental models (project-conventions, project-decisions, user-preferences) that do not yet exist on the bank. |
Files
Editing
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
edit.mode | Edit Mode | enum | hashline | Select the edit tool variant (replace, patch, hashline, or apply_patch). Values: apply_patch, hashline, patch, replace. |
edit.fuzzyMatch | Fuzzy Match | boolean | true | Accept high-confidence fuzzy matches for whitespace differences. |
edit.fuzzyThreshold | Fuzzy Match Threshold | number | 0.95 | Similarity threshold (0-1) for accepting fuzzy matches. |
edit.streamingAbort | Abort on Failed Preview | boolean | false | Abort streaming edit tool calls when patch preview fails. |
edit.blockAutoGenerated | Block Auto-Generated Files | boolean | true | Prevent editing of files that appear to be auto-generated (protoc, sqlc, swagger, etc.). |
edit.afterEdit | After an Edit | enum | verify | What happens when a turn ends having changed files: verify runs one check when none followed the last edit, review reads back every file the turn changed and judges correctness, maintainability and cross-file contracts, off ends the turn where the model ends it. Values: verify, review, off. |
Reading
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
readLineNumbers | Line Numbers | boolean | false | Prepend line numbers to read tool output by default. |
read.defaultLimit | Default Read Limit | number | 300 | Line count returned when read is called without one. The window also stops at the tool output budget, so a file of long lines returns fewer lines than this. |
read.toolResultPreview | Inline Read Previews | boolean | false | Render read tool results inline in the transcript instead of summary rows. |
Read Summaries
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
read.summarize.enabled | Enable Read Summaries | boolean | true | Return structural code summaries when read is called without an explicit selector. |
read.summarize.prose | Prose Summaries | boolean | false | Return structural summaries for Markdown and plain text reads. |
read.summarize.minBodyLines | Read Summary Body Lines | number | 4 | Minimum multiline body or literal length before read summaries collapse it. |
read.summarize.minCommentLines | Read Summary Comment Lines | number | 6 | Minimum multiline block comment length before read summaries collapse it. |
read.summarize.minTotalLines | Read Summary Minimum File Length | number | 100 | Files with fewer total lines are read verbatim instead of structurally summarized. |
read.summarize.unfoldUntil | Read Summary Unfold Target | number | 50 | BFS-unfold elidable spans until the summary is at least this many visible lines. 0 keeps only the outermost elisions. |
read.summarize.unfoldLimit | Read Summary Unfold Ceiling | number | 100 | Hard ceiling on summary size while BFS-unfolding. An unfold whose revealed lines would exceed this is skipped (that span stays folded) and unfolding continues with the remaining spans. |
LSP
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
lsp.enabled | Language Servers | boolean | false | Start language servers. Files → LSP is the row you enter; this switch and the others on that page (agent tool, diagnostics after write, diagnostics after edit, format after write) are independent once servers are running. |
lsp.tool | Agent Tool | boolean | true | Give the agent the lsp tool (definitions, references, rename, query diagnostics). Off keeps servers for format and injected diagnostics only. |
lsp.lazy | Lazy Startup | boolean | true | Start language servers on first use (lsp tool or editing a matching file type) instead of at session startup. |
lsp.formatOnWrite | Format after Write | boolean | false | Format the file with the language server after the write tool saves it. Independent of the agent tool and of diagnostics. |
lsp.diagnosticsOnWrite | Diagnostics after Write | boolean | true | After the write tool saves a file, inject language-server diagnostics into the session. Independent of the agent tool. |
lsp.diagnosticsOnEdit | Diagnostics after Edit | boolean | false | After the edit tool saves a file, inject language-server diagnostics into the session. Independent of the agent tool. |
lsp.diagnosticsDeduplicate | Deduplicate Diagnostics | boolean | true | Suppress post-edit LSP diagnostics already shown for a file; only surface new or changed ones. |
Shell
Bash
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
bash.enabled | Enable Bash | boolean | true | Enable the bash tool for shell command execution. |
bash.stallDetection.enabled | Bash Stall Detection | boolean | false | Watch for a bash command that stops producing output; background it and tell the model it may be stuck so it can cancel a truly hung command. Recommends, never force-kills. |
bashInterceptor.enabled | Bash Interceptor | boolean | false | Block shell commands that have dedicated tools. |
shellMinimizer.enabled | Shell Minimizer | boolean | true | Compress verbose shell output (git, npm, cargo, etc.) before returning it to the agent. |
shellMinimizer.sourceOutlineLevel | Shell Minimizer Source Outline | enum | default | Source outline mode for cat/read of source files: default or aggressive. Values: default, aggressive. |
bash.autoBackground.enabled | Bash Auto-Background | boolean | true | Move a long-running bash command to a background job on its own and deliver the result when it lands, instead of holding the turn open. Off, a command holds the foreground until it finishes or times out. Either way you can background the running command yourself with the composer’s background key. |
bash.autoBackground.thresholdMs | Auto-Background After | number | 300000 | Max wall-clock time a bash call runs in the foreground before it is moved to a background job (result delivered later). Frees the model to keep working and protects the prompt cache, which a long foreground command would otherwise blow past. Fires on elapsed time even while output is streaming. 0 backgrounds immediately. |
bash.stallDetection.stallMs | Stall After | number | 30000 | When stall detection is on, how long a bash call may produce no new output before it is treated as possibly stuck, backgrounded, and flagged so the model can cancel it if it is truly hung. Measures idle time (quiet output), not total run time. |
Eval & Runtimes
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
eval.py | Python Eval Backend | boolean | true | Allow the eval tool to dispatch Python cells to the IPython kernel. |
eval.js | JavaScript Eval Backend | boolean | true | Allow the eval tool to dispatch JavaScript cells to the in-process runtime. |
eval.rb | Ruby Eval Backend | boolean | false | Allow the eval tool to dispatch Ruby cells to the persistent Ruby kernel. |
eval.jl | Julia Eval Backend | boolean | false | Allow the eval tool to dispatch Julia cells to the persistent Julia kernel. |
ruby.kernelMode | Ruby Kernel Mode | enum | session | Keep the Ruby kernel alive across eval calls or start fresh each time. Values: session, per-call. |
julia.kernelMode | Julia Kernel Mode | enum | session | Keep the Julia kernel alive across eval calls or start fresh each time. Values: session, per-call. |
python.kernelMode | Python Kernel Mode | enum | session | Keep the IPython kernel alive across eval calls or start fresh each time. Values: session, per-call. |
python.interpreter | Python Interpreter | string | (empty) | Optional path to an exact Python executable. When set, automatic Python runtime discovery is skipped. |
ruby.interpreter | Ruby Interpreter | string | (empty) | Optional path to an exact Ruby executable. When set, automatic Ruby runtime discovery is skipped. |
julia.interpreter | Julia Interpreter | string | (empty) | Optional path to an exact Julia executable. When set, automatic Julia runtime discovery is skipped. |
Tools
Available Tools
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
todo.enabled | Todos | boolean | true | Enable the todo tool for task tracking. |
astEdit.enabled | AST Edit | boolean | true | Enable the ast_edit tool for structural AST rewrites. |
debug.enabled | Debug | boolean | true | Enable the debug tool for DAP-based debugging. The tool loads only where a configured adapter command resolves. |
launch.enabled | Launch | boolean | true | Enable the launch tool for supervising shared long-running project processes. |
speechgen.enabled | Speech Generation | boolean | false | Enable the tts tool for on-device (Kokoro) or xAI Grok Voice speech-file synthesis. |
generate_image.enabled | Generate Image | boolean | false | Enable the generate_image tool for text-to-image generation and editing. |
inspect_image.enabled | Inspect Image | boolean | false | Enable the inspect_image tool, delegating image understanding to a vision-capable model. |
checkpoint.enabled | Checkpoint/Rewind | boolean | false | Enable the checkpoint and rewind tools for context checkpointing. |
fetch.enabled | Read URLs | boolean | true | Allow the read tool to fetch and process URLs. |
vault.enabled | Obsidian Vault | boolean | false | Enable the vault:// internal URL for reading and editing Obsidian vault content via the Obsidian CLI. When disabled, vault:// resolution is refused and the vault:// entry is omitted from the system prompt. |
github.enabled | GitHub CLI | boolean | false | Enable the github tool (op-based dispatch for repository, issue, pull request, diff, search, checkout, push, and Actions watch workflows). |
web_search.enabled | Web Search | boolean | true | Enable the web_search tool for live web results. |
ask.enabled | Ask | boolean | true | Enable the ask tool for interactive user questions. |
browser.enabled | Browser | boolean | false | Enable the browser tool for scripted Chromium automation (puppeteer). |
Todos
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
todo.reminders | Todo Reminders | boolean | true | Prompt continued execution when unfinished todos remain. |
todo.reminders.max | Todo Reminder Limit | number | 3 | Maximum distinct todo-state reminders before reminders stay silent. |
todo.eager | Create Todos Automatically | enum | default | How strongly to push automatic todo-list creation after the first message. Values: default, preferred, always. |
tasks.todoClearDelay | Todo Auto-Clear Delay | number | -1 | Delay before completed or abandoned todos are removed from the todo widget. |
Launch
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
launch.cleanupWaitMs | Launch Cleanup Wait | number | 900000 | How long an exited process record is retained before being purged from memory and disk (0 = never clean up). |
Search Context
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
search.contextBefore | Text Context Before | number | 1 | Lines of context before each text search match. |
search.contextAfter | Text Context After | number | 1 | Lines of context after each text search match. |
Browser
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
browser.headless | Headless Browser | boolean | true | Launch browser in headless mode (disable to show browser UI). |
browser.cmux | cmux Browser | boolean | true | Use cmux WKWebView surfaces for browser automation when a cmux socket is available. Set VEYYON_BROWSER_CMUX=0 or VEYYON_BROWSER_CMUX=1 to override. |
browser.screenshotDir | Screenshot Directory | string | (unset) | Directory to save screenshots. If unset, screenshots go to a temp file. Supports ~. Examples: ~/Downloads, ~/Desktop, /sdcard/Download (Android). |
GitHub
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
github.cache.enabled | GitHub View Cache | boolean | true | Cache rendered issue/PR view output in the active profile’s cache/github-cache.db so repeated reads are free. |
github.cache.softTtlSec | GitHub Cache Soft TTL | number | 300 | Within this window, cached issue/PR view rows are returned directly (seconds; default 5 minutes). |
github.cache.hardTtlSec | GitHub Cache Hard TTL | number | 604800 | Past the soft TTL the cached row is returned and refreshed in the background; past the hard TTL it is dropped (seconds; default 7 days). |
Output Limits
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
tools.artifactSpillThreshold | Artifact Spill Threshold (KB) | number | 50 | Tool output above this size is saved as an artifact and the result keeps a head/tail window no larger than this size, plus the artifact:// id that reads the full text back, so a lower threshold costs a re-read rather than losing output. It governs every tool that streams output, including bash, eval, ssh and the interactive shell, as well as search and the browser. |
tools.artifactTailBytes | Artifact Tail Size (KB) | number | 20 | Amount of tail content kept inline when output spills to artifact, bounded by the spill threshold. |
tools.artifactHeadBytes | Artifact Head Size (KB) | number | 20 | Amount of head content kept inline alongside the tail when output spills to artifact (middle elision), bounded with the tail by the spill threshold. 0 disables — keep tail only. |
tools.outputMaxColumns | Output Column Cap | number | 768 | Per-line byte cap for streaming tool outputs (bash, ssh, python, js eval) and read. Lines wider than this are ellipsis-truncated; remaining bytes up to the next newline are dropped. 0 disables. |
tools.artifactTailLines | Artifact Tail Lines | number | 500 | Maximum lines of tail content kept inline when output spills to artifact. |
tools.inlineOutputFloor | Inline Output Floor | number | 0.25 | Smallest share of the inline output budget an early tool result may use before the rest spills to an artifact. A result that arrives early is re-read on every later turn, so it is charged more tightly than one that arrives near the end. Lower spills sooner and costs fewer context tokens; 1 keeps the flat cap and never spills early. This governs every tool that streams output, including eval, bash, ssh and the interactive shell, as well as search and the browser. Shown under the tab’s Advanced fold. |
Execution
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
tools.intentTracing | Intent Tracing | boolean | true | Ask the agent to describe the intent of each tool call before executing it. |
tools.abortOnFabricatedResult | Abort On Fabricated Tool Result | boolean | true | With in-band tool calls, stop the model immediately when it starts hallucinating a tool result mid-turn. Disable to let the model finish generating and discard the fabricated continuation instead. |
tools.maxTimeout | Max Tool Timeout | number | 0 | Maximum timeout in seconds the agent can set for any tool (0 = no limit). |
async.enabled | Async Execution | boolean | true | Enable async bash commands and background task execution. |
async.pollWaitDuration | Max Poll Time | enum | smart | How long the poll tool waits for background job updates before returning the current state. A fixed value waits that exact duration every time. smart adapts: it starts at 30s and climbs to 4m on a back-to-back poll, then resets to 30s after about a minute without polling. The 4m ceiling stays below the 5-minute prompt-cache boundary. Values: 5s, 10s, 30s, 1m, 5m, smart. |
Discovery & MCP
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
tools.discoveryMode | Tool Discovery | enum | auto | Hide tools behind a search tool to save tokens. ‘auto’ hides MCP tools once the tool set has more than 40 tools; ‘mcp-only’ always hides MCP tools; ‘all’ also hides non-essential built-ins and first-party heavyweight tools such as generate_image. Values: auto, off, mcp-only, all. |
tools.essentialOverride | Essential Tools Override | array | [] | Override the always-loaded built-in tools (default: read, bash, launch, edit, write, search, eval). Leave empty to use defaults. |
mcp.discoveryMode | MCP Tool Discovery | boolean | false | Hide MCP tools by default and expose them through a tool discovery tool. |
mcp.discoveryDefaultServers | MCP Discovery Default Servers | array | [] | Keep MCP tools from these servers visible while discovery mode hides other MCP tools. |
mcp.notifications | MCP Update Injection | boolean | false | Inject MCP resource updates into the agent conversation. |
mcp.notificationDebounceMs | MCP Notification Debounce | number | 500 | Debounce window in milliseconds for MCP resource updates before injecting them into the conversation. |
Developer
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
dev.autoqa | Auto QA | boolean | false | Record unexpected built-in tool behavior in this profile’s local grievance database. |
dev.autoqaPush.enabled | Auto-upload Grievances | boolean | false | Send new and queued grievances to veyyon.dev after recording them. Off keeps reports local until you run veyyon grievances push. |
dev.autoqaPush.endpoint | Grievance Upload Endpoint | string | https://veyyon.dev/api/grievances | Destination for automatic and manual grievance uploads. |
Tasks
Modes
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
plan.enabled | Plan Mode | boolean | true | Enable plan mode for read-only exploration and planning before execution. |
plan.defaultOnStartup | Start in Plan Mode | boolean | false | Automatically enter plan mode at the start of every new session. |
goal.enabled | Goal Mode | boolean | true | Enable per-session goal mode and the hidden goal tool. |
goal.modelBudgetsEnabled | Model Goal Budgets | boolean | false | Expose and enforce persisted per-goal token budgets for the model. This control is available only in Settings. |
goal.statusInFooter | Goal Progress Bar in Footer | boolean | true | Add a compact progress bar next to the goal token count in the status line. The token count is always shown; this controls the extra bar. |
goal.continuationModes | Goal Continuation Modes | array | ["interactive"] | Run modes where active goals may auto-continue between turns. |
title.refreshOnReplan | Refresh Title on Replan | boolean | true | Refresh generated session titles after todo init replans unless the title was set by the user. |
Commands & Skills
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
skills.enableSkillCommands | Skill Commands | boolean | true | Register skills as /skill:name commands. |
commands.enableClaudeUser | Claude User Commands | boolean | true | Load commands from ~/.claude/commands/. |
commands.enableOpencodeUser | OpenCode User Commands | boolean | true | Load commands from ~/.config/opencode/commands/. |
Subagents
Delegation
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
subagent.enabled | Subagents | boolean | true | Whether this session may use subagents at all. Off removes the task tool and every delegation instruction from the prompt, so nothing can be spawned. This is the only setting that takes the ability away: Subagent Delegation below decides how hard the model is PUSHED to delegate, never whether it may. Your delegation strength and your Roster are kept while this is off and take effect again when you turn it back on. |
subagent.delegation | Subagent Delegation | enum | preferred | How strongly this session routes work to the subagent types you enabled. Allowed leaves delegation available without prompting for it. Preferred asks for substantial eligible work to be delegated. Required adds a first-turn reminder. The enabled Roster is the routing policy: each name is a distinct type that owns only work matching its description, no type is a fallback for another, and work no enabled type covers stays with the main agent. Turn Subagents off above to remove delegation entirely. Values: allowed, preferred, required. |
subagent.batch | Batch Task Calls | boolean | true | Switch the task tool to its batch shape: one call carries { agent, context, tasks[] } — one subagent per item (with per-item isolation) and a required shared context prepended to every assignment. With async.enabled=true, each spawn runs as an independent background agent with the normal idle/parked lifecycle; otherwise the call blocks for merged results. Disable to restore the flat single-spawn schema. Shown under the tab’s Advanced fold. |
Subagents
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
subagent.agents | Roster | record | {} | Which subagent types the model may choose, and what each one runs. Enabled means the model can pick that subagent on its own; disabled means it cannot. With no row, only the general-purpose deep worker is enabled. Bundled specialists and subagents you add are opt-in through onboarding or this roster. Each subagent’s page carries its own Model and Effort, and a Subagents chain naming what it may spawn in turn, level by level; unset anywhere follows the level above, and an agent that names nothing runs the default model role. Same Model for All Subagents below replaces the per-agent Model and Effort rows with one pair for the whole roster. |
subagent.maxNestedSpawnDepth | Max Nested Spawn Depth | number | 0 | How many nested levels subagents may spawn, for every level no roster chain decides. 0 still lets this session spawn direct subagents, but those children do not receive the task tool. Open Roster above, pick a subagent, then Subagents, to turn individual levels on or off for that one; this number answers from the first level its chain does not name. |
subagent.sharedModel | Same Model for All Subagents | boolean | false | Run every subagent on one model and one effort instead of choosing per agent. Off, each agent’s page decides. On, the two rows below decide for the whole roster and the per-agent Model and Effort rows are hidden; what those rows hold is kept and comes back when this goes off. |
subagent.model | Shared Model | modelChain | (unset) | The model chain every subagent runs while Same Model for All Subagents is on. Unset falls back to the default model role, the same model a new session starts on. |
subagent.thinkingLevel | Shared Effort | string | (unset) | The effort every subagent runs at while Same Model for All Subagents is on. Narrowed to what the model above declares; a :level suffix on the chain still wins. Inherit leaves the documented default. |
subagent.showResolvedModelBadge | Show Resolved Model Badge | boolean | true | Show each subagent’s resolved model, and the setting that decided it, in the task widget status line and the agent surfaces. Shown under the tab’s Advanced fold. |
Limits
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
subagent.maxConcurrency | Max Concurrent Subagents | number | 32 | Maximum number of subagents running concurrently. |
subagent.maxRuntimeMs | Max Subagent Runtime | number | 0 | Hard wall-clock limit per subagent (ms). 0 disables it. Defense-in-depth against provider-side stream hangs that escape the inference-layer watchdog; triggers a normal subagent abort with a ‘timed out’ reason. |
subagent.softRequestBudget | Soft Request Budget | number | 200 | Soft per-subagent request budget (assistant requests per run). Crossing it injects a wrap-up steering notice (see the notice setting below); at 1.5x the budget the run is force-stopped and the agent must yield its partial findings. 0 disables the guard. Bundled scout/sonic agents use a lower built-in budget. |
subagent.softRequestBudgetNotice | Soft Request Budget Notice | boolean | true | Inject one steering notice when a subagent crosses its soft request budget, asking it to wrap up before the 1.5x forced-yield stop. |
subagent.enableLsp | LSP in Subagents | boolean | false | Allow spawned subagents to use the lsp tool. Off by default to keep subagents cheap; enable when LSP-aware delegation is worth the extra tokens. |
Park
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
subagent.idleTtlMs | Park After | number | 300000 | Stage one. How long a finished subagent stays live before it parks (ms). Parking releases the live session — the process, its MCP clients, its memory — and keeps everything else: the row stays in the roster and the agent rebuilds itself when messaged or opened. Counted from the agent’s last activity, so a revived agent starts this budget again from the revival. ‘Until exit’ keeps idle agents live for the whole session. |
Prune
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
subagent.prune.enabled | Prune Parked Subagents | boolean | true | Stage two, and a different thing from parking. Pruning takes a parked subagent out of the roster and gives up the ability to wake it; parking only released its session. Nothing on disk is touched: the transcript stays where it is and stays readable at history://\<agent>. Off keeps every parked subagent listed and wakeable until you exit. |
subagent.prune.afterMs | Prune After | number | 3600000 | How long a parked subagent stays in the roster before it is pruned (ms). Counted from its last activity, so a subagent read back from a previous run is judged on when its transcript was last written rather than on when this session found it. |
subagent.prune.waitingAfterMs | Prune After While Waiting | number | 7200000 | The same budget for a subagent whose last message said it was waiting on another agent (ms). It stopped on purpose to let a peer finish, so it keeps its row longer than one that simply went quiet: pruning it on the ordinary budget would drop the agent you are most likely to message next. Set it equal to Prune After to treat both the same; a shorter value is raised to it. |
Isolation
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
subagent.isolation.mode | Isolation Mode | enum | none | Isolation backend for subagents. “auto” lets the native PAL pick the best available backend (CoW-aware filesystems, then overlayfs/ProjFS, then a git worktree / recursive-copy fallback). Values: none, auto, apfs, btrfs, zfs, reflink, overlayfs, projfs, block-clone, rcopy. |
subagent.isolation.merge | Isolation Merge Strategy | enum | patch | How isolated subagent changes are integrated (patch apply or branch merge). Values: patch, branch. |
subagent.isolation.commits | Isolation Commit Style | enum | generic | Commit message style for nested repo changes (generic or AI-generated). Values: generic, ai. |
worktree.base | Worktree Base Directory | string | (unset) | Base directory for agent-managed worktrees: subagent isolation copies, github PR checkouts, and veyyon worktree cleanup all live here. Unset uses the active profile’s wt/ directory (~/.veyyon/profiles/<name>/wt, or its XDG data equivalent). Must be an absolute or ~-relative path; relative paths are ignored. The VEYYON_WORKTREE_DIR env var overrides this. |
Coordination
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
irc.timeoutMs | IRC Timeout | number | 120000 | Default timeout for irc wait (and send await:true) in milliseconds; 0 disables the timeout. IRC is how a parent and its subagents talk, which is why it is configured here. |
Providers
Accounts
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
accounts.loadBalancing | Account Load Balancing | boolean | false | Off: only the account you chose is used, and a session waits out its quota window. On: when that account hits its quota or rate limit, continue on another account of the same provider and say so. A revoked account always fails over regardless, with a notice. |
Services
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
providers.maxInFlightRequests | Max In-Flight Requests | record | {} | Maximum concurrent LLM requests per provider id (for example “openai” or “anthropic”), shared across local veyyon processes with this config root. Omitted providers are unlimited. |
providers.ollama-cloud.maxConcurrency | Ollama Cloud Max Concurrency | number | 3 | Maximum concurrent Ollama Cloud subagent runs per process; 0 disables the provider-specific limit. |
providers.webSearch | Web Search Provider | enum | auto | The provider web_search uses; auto tries each in turn. Values: auto, perplexity, gemini, anthropic, codex, xai, zai, exa, tinyfish, jina, kagi, tavily, firecrawl, brave, kimi, parallel, synthetic, searxng, startpage, duckduckgo, google, mojeek, public. |
providers.webSearchExclude | Excluded Web Search Providers | array | [] | Providers that web_search should never use, even as fallbacks. |
providers.webSearchGeminiModel | Gemini web_search model | string | (unset) | Model ID for Gemini Google Search grounding. Defaults to gemini-2.5-flash. |
providers.antigravityEndpoint | Antigravity Endpoint Mode | enum | auto | Endpoint routing strategy for google-antigravity providers (chat, search, image, discovery). Values: auto, production, sandbox. |
providers.image | Image Provider | enum | auto | Preferred provider for image generation. Values: auto, openai, antigravity, xai, gemini, openrouter. |
providers.tts | Text-to-Speech Provider | enum | auto | Backend for the tts tool: local on-device neural TTS (Kokoro-82M) or xAI Grok Voice. Values: auto, local, xai. |
tts.localModel | Local TTS Model | enum | kokoro | On-device neural TTS model (Kokoro-82M) used by the local TTS backend. Values: kokoro. |
tts.localVoice | Local TTS Voice | enum | af_heart | Kokoro voice used by the local TTS backend (American/British, female/male). Values: af_heart, af_bella, af_nicole, af_aoede, af_kore, af_sarah, am_michael, am_fenrir, am_puck, bf_emma, bm_george, bm_fable. |
speech.enabled | Speech Vocalization | boolean | false | Speak the assistant’s output aloud through the speakers as it streams. |
speech.mode | Speech Vocalization Mode | enum | assistant | What to speak: all = assistant messages + thinking; assistant = messages only; yield = only the final message at turn end. Values: all, assistant, yield. |
speech.enhanced | Enhanced Speech Rewriting | boolean | false | Rewrite assistant output into natural spoken prose with the tiny/smol model before synthesis (describes code, drops links and markdown). Falls back to mechanical cleanup on failure. |
speech.voice | Speech Vocalization Voice | enum | af_heart | Kokoro voice used when speaking the assistant’s output aloud. Values: af_heart, af_bella, af_nicole, af_aoede, af_kore, af_sarah, am_michael, am_fenrir, am_puck, bf_emma, bm_george, bm_fable. |
providers.fetch | Fetch Provider | enum | auto | Reader backend priority for the fetch/read URL tool. Values: auto, native, trafilatura, lynx, parallel, jina. |
codexResets.autoRedeem | Codex Auto-Redeem Saved Resets | enum | unset | When a turn is blocked by the Codex weekly limit on the active account and no other account is available, run the conservative saved-reset check. unset asks before spending the first eligible reset, yes spends eligible resets without prompting, and no disables the check entirely. Requires retries enabled. Values: unset, yes, no. |
codexResets.minBlockedMinutes | Codex Auto-Redeem Min Block | number | 60 | Only auto-redeem when the natural weekly reset is at least this many minutes away (don’t spend a ~30-day credit to save a short wait). |
codexResets.keepCredits | Codex Auto-Redeem Reserve | number | 0 | Never auto-spend below this many saved resets (0 = the last credit may be spent automatically). |
exa.enabled | Exa | boolean | true | Master toggle for all Exa search tools. |
exa.enableSearch | Exa Search | boolean | true | Enable Exa basic search, deep search, code search, and crawl tools. |
exa.searchDelayMs | Exa Search Delay | number | 1000 | Minimum delay between Exa web search requests in milliseconds; set 0 to disable pacing. |
exa.enableResearcher | Exa Researcher | boolean | false | Enable the Exa researcher tool for AI-powered deep research. |
exa.enableWebsets | Exa Websets | boolean | false | Enable Exa webset management and enrichment tools. |
searxng.endpoint | SearXNG Endpoint | string | (unset) | Base URL of a self-hosted SearXNG instance used for web search. |
Discovery
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
discovery.importForeignConfig | Import Other Tools’ Config | boolean | false | Auto-discover skills, context files, rules, and MCP servers authored for other AI tools (Claude, Codex, Gemini, Cursor, opencode, and more) found on disk. Off by default: veyyon runs on its own instruction layers only (the system prompt, the global ~/.veyyon/AGENTS.md, the active profile’s AGENTS.md, and the project’s own AGENTS.md/CLAUDE.md walked from the repo root down to cwd), and never ambiently picks up a foreign tool’s own config directory, GEMINI.md, or the skills, rules and MCP servers those tools define. Turn on to import them as a base layer. |
Fireworks
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
providers.fireworksTier | Fireworks Tier | enum | standard | Serving path for Fireworks requests. Priority sends service_tier: "priority" for higher reliability during peak traffic at a higher price; Standard omits it. Fast (-fast) models ignore this — Fast is its own serving path. Values: standard, priority. |
Tiny Model
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
providers.tinyModel | Session Title Model | enum | online | Session-title model: online (the TINY role from /models, else @smol) by default, or a local on-device model. Values: online, lfm2-350m, qwen3-0.6b, gemma-270m, qwen2.5-0.5b, lfm2-700m. |
providers.tinyModelDevice | Tiny Model Device | enum | default | ONNX execution provider for local tiny models (titles + memory). Default uses CPU-only inference. The VEYYON_TINY_DEVICE env var overrides this. Values: default, gpu, cpu, metal, webgpu, cuda, dml, coreml, auto, wasm, webnn, webnn-gpu, webnn-cpu, webnn-npu. |
providers.tinyModelDtype | Tiny Model Precision | enum | default | ONNX quantization/precision for local tiny models. Default uses each model’s shipped dtype (q4); lower precision is faster, higher is more faithful. The VEYYON_TINY_DTYPE env var overrides this. Values: default, q4, q4f16, q8, fp16, fp32, int8, uint8, bnb4, q2, q2f16, q1, q1f16, auto. |
providers.unexpectedStopModel | Unexpected Stop Model | enum | online | Classifier for unexpected-stop detection: online (the TINY role from /models, else smol) by default, or a local on-device model. Values: online, qwen3-1.7b, llama3.2:3b, gemma-3-1b, qwen2.5-1.5b, lfm2-1.2b. |
Protocol
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
providers.kimiApiFormat | Kimi API Format | enum | anthropic | API format for Kimi Code provider. Values: openai, anthropic. |
providers.openaiWebsockets | OpenAI WebSockets | enum | auto | Websocket policy for OpenAI Codex models (auto uses model defaults, on forces, off disables). Values: auto, off, on. |
providers.openrouterVariant | OpenRouter Routing | enum | default | Default routing-variant suffix appended to OpenRouter model IDs (overridden when the selector already names a variant). Values: default, nitro, floor, online, exacto. |
provider.appendOnlyContext | Append-Only Context | enum | auto | Cache system prompt + tool specs and keep an append-only message log so provider prefix caches (DeepSeek, Xiaomi/SGLang, Anthropic) hit at maximum rate. Auto enables for known prefix-cache providers. Values: auto, on, off. |
Timeouts
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
providers.streamFirstEventTimeoutSeconds | Stream First Event Timeout | number | -1 | Seconds to wait for the first model stream event; -1 uses provider/env defaults, 0 disables the watchdog. |
providers.streamIdleTimeoutSeconds | Stream Idle Timeout | number | -1 | Seconds a model stream may stay silent between events; -1 uses provider/env defaults, 0 disables the watchdog. |
Privacy
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
images.blockImages | Block Images | boolean | false | Prevent images from being sent to LLM providers. |
secrets.enabled | Hide Secrets | boolean | false | Obfuscate secrets before sending to AI providers. Storing a credential with /secret turns this on for you. |
secrets.defaultTtl | Secret Lifetime | string | 1d | How long a /secret lasts when the command does not say. Default 1d; also accepts forms like 30m, 12h, 7d, 2w, or “never”. |
secrets.auditLog | Record Secret Use | boolean | true | Append which secret was used in which command to the profile’s log. Never records values. |
secrets.expiryWarnings | Warn Before A Secret Expires | boolean | true | Say at the start of a session when a stored secret is halfway through its lifetime, and again near the end. Off: /secret list still shows the STATUS column and the status line still shows a deadline in the last hour. |
Experimental
Argot
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
argot.enabled | Argot Shorthand | boolean | false | Let the agent load token-saving shorthand for the projects it works in, kept in a local cache (nothing is written to the repository). The project you launch in is loaded for you, and the model loads any further project with the argot_load tool; it then writes short handles that the harness expands to full text before any tool runs or the display shows them. |
argot.autoload | Argot Startup Load | boolean | true | Load the project you started the session in, in the background, so shorthand works without the model spending a turn on it. Off, a session starts with no dictionary until the model calls argot_load itself. Either way a handle already written still expands. |
argot.encode.models | Argot Models | array | [] | Models allowed to write Argot shorthand, by model id. Empty (the default) means no model does, so turning Argot on alone stays inert until you add one here. A model left off this list is never taught the shorthand; handles already in history still expand. |
argot.tokenBudget | Argot Dictionary Budget | number | 1000 | How many tokens the generated Argot dictionary may spend on its handle table. A larger budget teaches more handles (more transcript savings) but adds a longer preamble each turn; a smaller budget teaches only the most central strings. Changing it regenerates the dictionary. |
argot.encode.disableAboveTokens | Argot Context Cutoff | number | -1 | Stop teaching Argot shorthand once context passes this many tokens (the model then writes in full). Handles already written still expand losslessly. -1 disables the cutoff. |
argot.subagents | Argot in Subagents | enum | off | How a subagent starts with Argot shorthand. Correctness never depends on this (handles never cross the parent/child wire); it only trades tokens. off: no shorthand in subagents. fresh: the subagent loads its task’s project itself through argot_load. inherit: the subagent starts from a copy of the parent’s loaded shorthand. Values: off, fresh, inherit. |
Tool Calling
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
tools.format | Tool Calling Mode | enum | auto | Controls how tools are exposed to the model. Auto uses provider-native tool calls unless the selected model is marked as not supporting them, then falls back to the GLM owned dialect. Native forces provider-native tools; the other values force the named owned dialect. Applies on session start. Values: auto, native, glm, hermes, kimi, xml, anthropic, deepseek, harmony, qwen3, gemini, gemma, minimax, pi-native. |
Auto-Learn
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
autolearn.enabled | Enable Auto-Learn | boolean | false | After the agent stops, nudge it to capture lessons to memory and create/enhance isolated managed skills. |
autolearn.autoContinue | Auto-run capture at stop | boolean | false | When on, auto-run one capture turn at stop (uses extra tokens). Off = passive reminder on your next turn. |
Global
Machine Limits
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
machine.cpuLimitCores | Machine CPU Limit | number | 0 | Maximum CPU every veyyon process on this machine may use TOGETHER, in cores (0 = no limit). Stored in ~/.veyyon/config.yml rather than in a profile, so it covers every profile and every veyyon running at once, which is what makes it a machine limit: held per profile, two profiles would read their own copy and the machine would get the sum. Each session’s budget group is created INSIDE this one, so on Linux the kernel caps the whole subtree and no combination of sessions can exceed it. A per-session limit larger than this one is bounded by it and does not raise it. The machine tier therefore needs a parent that delegates two levels; a host that delegates one, such as a container whose cgroup root holds processes, still holds per-session limits and reports the machine tier as unheld. Where the kernel cannot hold it, a notice says so once at startup rather than reporting a cap that does not exist. Stored machine-wide, not per profile. |
machine.memoryLimitGb | Machine Memory Limit | number | 0 | Maximum memory every veyyon process on this machine may hold together, in gigabytes (0 = no limit). Stored in ~/.veyyon/config.yml, so it spans profiles and concurrent veyyon instances. Every session budget group sits inside this one, so on Linux this is cgroup v2 memory.max on the parent, with memory.swap.max pinned to 0 so the cap is the whole anonymous footprint rather than a resident cap a process escapes by swapping. The kernel reclaims, then OOM-kills, inside the subtree once the total is reached — whichever process the kernel picks, with no warning and no chance to finish. Set it where an OOM kill is preferable to the machine swapping. Where no memory controller is delegated the cap cannot be held, and a notice says so once at startup. Stored machine-wide, not per profile. |
machine.writeBudgetGb | Machine Write Budget | number | 0 | Cumulative gigabytes every veyyon process on this machine may WRITE before further writes are refused (0 = no limit). Stored in ~/.veyyon/config.yml, so it spans profiles and concurrent veyyon instances. Unlike CPU and memory this is a total that accumulates, not a level: it counts bytes written since the machine budget was last reset, across every session, and refuses new commands and harness writes once the total is reached. A write budget is the one limit no kernel enforces on its own — cgroup io accounting MEASURES bytes and caps rate, not a lifetime total — so this is a refusal, and work already writing runs to completion. Stored machine-wide, not per profile. |
machine.maxProcesses | Machine Max Processes | number | 0 | Hard cap on how many processes every veyyon on this machine may have alive at once (0 = no limit). Stored in ~/.veyyon/config.yml, so it spans profiles and concurrent veyyon instances. Every session budget group sits inside this one, so on Linux this is cgroup v2 pids.max on the parent and the kernel refuses the fork itself once the subtree is full, whichever session asked. Where pids is not delegated the cap is a refusal at the spawn path instead, and a notice says so once at startup. Stored machine-wide, not per profile. |
Profiles
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
defaultProfile | Default Profile | string | default | Profile used when no –profile flag or VEYYON_PROFILE is set. Stored in ~/.veyyon/config.yml. Use the profile name (default clears the override). Stored machine-wide, not per profile. |
onboardingVersion | Onboarding Version | number | 0 | Setup generation this machine has already completed. Stored in ~/.veyyon/config.yml, so switching profile or working directory never re-runs onboarding. Stored machine-wide, not per profile. |
Credentials
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
profileSharing | Share Credentials Across Profiles | boolean | true | When on (the default), every profile reads one machine-wide set of provider logins. Turn off to give each profile its own private credential store. Changing this setting shuts down the active session; restart is required before any further model dispatch. Stored machine-wide, not per profile. |
Auth Broker
| Key | Setting | Type | Default | What it does |
|---|---|---|---|---|
authBrokerUrl | Auth Broker URL | string | (empty) | Base URL of the auth broker that mints provider credentials for this machine. Stored in ~/.veyyon/config.yml under auth.broker.url; empty disables broker discovery via config. Stored machine-wide, not per profile. |
authBrokerToken | Auth Broker Token | string | (empty) | Bearer token for the auth broker. Write-only: a stored token shows as a mask and is never echoed. Enter a new value to replace it, leave the mask to keep it, or clear the field to delete it. Stored machine-wide, not per profile. |
Configuration file only
These keys are not in /settings. Some are state veyyon writes for itself (a schema version, an onboarding marker), some are credentials that belong in a secret store rather than on a settings screen, and the rest are shapes a selector cannot edit, such as a table of patterns. All of them are read by production code, all of them are valid in config.yml, and all of them can be set with veyyon config set.
| Key | Type | Default | Notes |
|---|---|---|---|
async.maxJobs | number | 100 | |
auth.broker.token | string | (unset) | |
auth.broker.url | string | (unset) | |
autolearn.minToolCalls | number | 5 | |
bashInterceptor.patterns | array | [{"pattern":"^\\s*(cat|head|tail|less|more)\\s+","tool":"read","message":"Use the readtool instead of cat/head/tail. It provides better context and handles binary files."},{"pattern":"^\\s*(grep|rg|ripgrep|ag|ack)\\s+","tool":"search","message":"Usesearchwithtype: "text"instead of shell grep/rg."},{"pattern":"^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)","tool":"search","message":"Usesearchwithtype: "files"instead of shell find/fd."},{"pattern":"^\\s*sed\\s+(-i|--in-place)","tool":"edit","message":"Use theedittool instead of sed -i. It provides diff preview and fuzzy matching."},{"pattern":"^\\s*perl\\s+.*-[pn]?i","tool":"edit","message":"Use theedittool instead of perl -i. It provides diff preview and fuzzy matching."},{"pattern":"^\\s*awk\\s+.*-i\\s+inplace","tool":"edit","message":"Use theedittool instead of awk -i inplace. It provides diff preview and fuzzy matching."},{"pattern":"^\\s*(echo|printf|cat\\s*\<\<)\\s+(?:(?:[^\"'>]|\"[^\"]*\"|'[^']*')|(?\<!\\|)>{1,2}\\|?\\s*(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))*(?\<!\\|)>{1,2}\\|?\\s*(?!(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))[$\\w./~\"'-]","tool":"write","message":"Use thewritetool instead of echo/cat redirection. It handles encoding and provides confirmation."},{"pattern":"^\\s*nohup\\s+|(?\<!&)\\&\\s*$","tool":"launch","message":"Use thelaunchtool instead of nohup or background shell syntax so the process stays observable and managed."},{"pattern":"^\\s*(?:(?:bun|npm|pnpm|yarn)\\s+(?:run\\s+)?(?:dev|start)(?:\\s|$)|(?:vite|next\\s+dev|nuxt\\s+dev|nodemon|lldb|gdb|tail\\s+-f)(?:\\s|$)|docker\\s+compose\\s+up(?!.*(?:\\s-d(?:\\s|$)|--detach))(?:\\s|$))","tool":"launch","message":"Use thelaunchtool for services, watchers, and debuggers so other veyyon instances can observe and control them."},{"pattern":"^\\s*(?:(?:bun|npm|pnpm|yarn)\\s+(?:run\\s+)?\\S+|cargo\\s+watch|watchexec|pytest|vitest|jest|tsc)(?:.|\\n)*(?:--watch|-w)(?:\\s|$)","tool":"launch","message":"Use thelaunch tool for watch mode so its output, input, and lifecycle stay managed."}] | |
branchSummary.reserveTokens | number | 16384 | |
commit.changelogMaxDiffChars | number | 120000 | |
commit.mapReduceEnabled | boolean | true | |
commit.mapReduceMaxConcurrency | number | 5 | |
commit.mapReduceMaxFileTokens | number | 50000 | |
commit.mapReduceMinFiles | number | 4 | |
commit.mapReduceTimeoutMs | number | 120000 | |
compaction.autoContinue | boolean | true | |
compaction.dropUseless | boolean | true | |
compaction.enabled | boolean | true | |
compaction.handoffSaveToDisk | boolean | false | |
compaction.idleEnabled | boolean | false | |
compaction.idleThresholdTokens | number | 200000 | |
compaction.idleTimeoutSeconds | number | 300 | |
compaction.keepRecentTokens | number | 10000 | |
compaction.midTurnEnabled | boolean | true | |
compaction.remoteEndpoint | string | (unset) | |
compaction.reserveTokens | number | (unset) | |
compaction.supersedeReads | boolean | true | |
compaction.thresholdPercent | number | -1 | Retired: use compaction.threshold instead. |
compaction.thresholdTokens | number | -1 | Retired: use compaction.threshold instead. |
cycleOrder | array | ["smol","slow"] | |
defaultThinkingLevel | enum | high | Values: minimal, low, medium, high, xhigh, max, auto. Retired: use defaultEffort instead. |
dev.autoqaPush.token | string | (unset) | |
disabledExtensions | array | [] | |
disabledProviders | array | [] | |
edit.modelVariants | record | {} | |
enabledModels | array | [] | |
eval.pyWorkspace | boolean | false | |
extensions | array | [] | |
gc.archive | boolean | true | |
gc.blobs | boolean | true | |
gc.coldArchiveAfterDays | number | 30 | |
gc.retainNewestGlobal | number | 20 | |
gc.retainNewestPerCwd | number | 10 | |
gc.wal | boolean | true | |
gc.writeGraceMinutes | number | 5 | |
harness.profiles | record | {} | |
hindsight.apiToken | string | (unset) | |
hindsight.bankIdPrefix | string | (unset) | |
hindsight.bankMission | string | (unset) | |
hindsight.debug | boolean | false | |
hindsight.mentalModelMaxRenderChars | number | 16000 | |
hindsight.mentalModelRefreshIntervalMs | number | 300000 | |
hindsight.recallBudget | enum | mid | Values: low, mid, high. |
hindsight.recallContextTurns | number | 1 | |
hindsight.recallMaxQueryChars | number | 800 | |
hindsight.recallMaxTokens | number | 1024 | |
hindsight.recallTimeoutMs | number | 30000 | |
hindsight.recallTypes | array | ["world","experience"] | |
hindsight.reflectTimeoutMs | number | 120000 | |
hindsight.requestTimeoutMs | number | 30000 | |
hindsight.retainContext | string | veyyon | |
hindsight.retainEveryNTurns | number | 3 | |
hindsight.retainMission | string | (unset) | |
hindsight.retainOverlapTurns | number | 2 | |
hindsight.retainTimeoutMs | number | 60000 | |
memories.enabled | boolean | false | |
memories.fallbackTokenLimit | number | 16000 | |
memories.maxRawMemoriesForGlobal | number | 200 | |
memories.maxRolloutAgeDays | number | 30 | |
memories.maxRolloutsPerStartup | number | 64 | |
memories.minRolloutIdleHours | number | 12 | |
memories.phase1InputTokenLimit | number | 4000 | |
memories.phase2HeartbeatSeconds | number | 30 | |
memories.phase2LeaseSeconds | number | 180 | |
memories.phase2RetryDelaySeconds | number | 180 | |
memories.rolloutPayloadPercent | number | 0.7 | |
memories.stage1Concurrency | number | 8 | |
memories.stage1LeaseSeconds | number | 120 | |
memories.stage1RetryDelaySeconds | number | 120 | |
memories.summaryInjectionTokenLimit | number | 5000 | |
memories.threadScanLimit | number | 300 | |
mnemopi.debug | boolean | false | |
mnemopi.injectionTokenLimit | number | 5000 | |
mnemopi.recallContextTurns | number | 3 | |
mnemopi.recallLimit | number | 8 | |
mnemopi.recallMaxQueryChars | number | 4000 | |
mnemopi.retainEveryNTurns | number | 4 | |
modelProviderOrder | array | [] | |
modelTags | record | {} | |
retry.baseDelayMs | number | 500 | |
retry.enabled | boolean | true | |
searxng.basicPassword | string | (unset) | |
searxng.basicUsername | string | (unset) | |
searxng.categories | string | (unset) | |
searxng.language | string | (unset) | |
searxng.token | string | (unset) | |
settingsMigrationVersion | number | 0 | |
setupVersion | number | 0 | Retired: use onboardingVersion instead. |
shellMinimizer.except | array | [] | |
shellMinimizer.legacyFilters | boolean | (unset) | |
shellMinimizer.maxCaptureBytes | number | 4194304 | |
shellMinimizer.only | array | [] | |
shellMinimizer.settingsPath | string | (unset) | |
shellPath | string | (unset) | |
skills.enabled | boolean | true | |
skills.ignoredSkills | array | [] | |
skills.includeSkills | array | [] | |
statusLine.leftSegments | array | [] | |
statusLine.rightSegments | array | [] | |
statusLine.segmentOptions | record | {} | |
statusLine.separator | enum | pipe | Values: powerline, powerline-thin, slash, pipe, block, none, ascii. |
statusLine.transparent | boolean | true | |
stt.language | string | en | |
subagent.modelByDepth | record | {} | Retired: use subagent.agents instead. |
thinkingBudgets.high | number | 16384 | |
thinkingBudgets.low | number | 2048 | |
thinkingBudgets.max | number | 32768 | |
thinkingBudgets.medium | number | 8192 | |
thinkingBudgets.minimal | number | 1024 | |
thinkingBudgets.xhigh | number | 32768 | |
ttsr.experimentalRules | array | [] | |
tui.maxInlineImageColumns | number | 100 | |
tui.maxInlineImageRows | number | 20 | |
tui.maxInlineImages | number | 8 |
353 settings in /settings, 121 configuration-file keys, 474 in all.
Model and Provider Configuration (models.yml / models.yaml)
How the coding agent loads models, applies overrides, resolves credentials, and chooses models at runtime.
What controls model behavior
Primary implementation files:
src/config/model-registry.ts: loads built-in + custom models, provider overrides, runtime discovery, auth integrationsrc/config/model-resolver.ts: parses model patterns and selects initial/smol/slow modelssrc/config/settings-schema.ts: model-related settings (modelRoles, provider transport preferences)src/session/auth-storage.ts: re-exportsAuthStoragefrom@veyyon/ai(packages/ai/src/auth-storage.ts); API key + OAuth resolution orderpackages/catalog/src/models.tsandpackages/catalog/src/types.ts: built-in providers/models (getBundledModels/getBundledProviders) andModel/compattypes
Config file location and legacy behavior
Default config paths, in precedence order:
~/.veyyon/profiles/default/agent/models.yml~/.veyyon/profiles/default/agent/models.yaml
Legacy behavior still present:
- If both YAML files are missing and
models.jsonexists at the same location, it is migrated tomodels.yml. - Explicit
.json/.jsoncconfig paths are still supported when passed programmatically toModelRegistry.
models.yml / models.yaml shape
providers:
<provider-id>:
# provider-level config
equivalence:
overrides:
<provider-id>/<model-id>: <canonical-model-id>
exclude:
- <provider-id>/<model-id>
provider-id is the canonical provider key used across selection and auth lookup.
equivalence is optional and configures canonical model grouping on top of concrete provider models:
overridesmaps an exact concrete selector (provider/modelId) to an official upstream canonical idexcludeopts a concrete selector out of canonical grouping
Provider-level fields
providers:
my-provider:
baseUrl: https://api.example.com/v1
apiKey: MY_PROVIDER_API_KEY
api: openai-completions
headers:
X-Team: platform
authHeader: true
auth: apiKey
disableStrictTools: false # set true for Anthropic-compatible endpoints that reject the strict field
discovery:
type: ollama
modelOverrides:
some-model-id:
name: Renamed model
models:
- id: some-model-id
name: Some Model
api: openai-completions
reasoning: false
input: [text]
cost:
input: 0
output: 0
cacheRead: 0
cacheWrite: 0
contextWindow: 128000
maxTokens: 16384
headers:
X-Model: value
compat:
supportsStore: true
supportsDeveloperRole: true
supportsReasoningEffort: true
maxTokensField: max_completion_tokens
openRouterRouting:
only: [anthropic]
vercelGatewayRouting:
order: [anthropic, openai]
extraBody:
gateway: m1-01
controller: mlx
baseUrl must be an absolute URL that begins with http:// or https://, at both provider and model level. A scheme-less value like localhost:11434 or 192.168.1.5:8080 is rejected when the config loads, because it is not a usable endpoint: it either fails to parse or parses with an empty host, so requests go nowhere and prefix cache reuse for a local server never turns on. Write http://localhost:11434 for a local server, or https://… for a remote one. The config is not normalised for you, so that a public host you meant to reach over https is never quietly downgraded to plaintext.
Allowed provider/model api values
openai-completionsopenai-responsesopenai-codex-responsesazure-openai-responsesanthropic-messagesgoogle-generative-aigoogle-gemini-cligoogle-vertex
Allowed auth/discovery values
auth:apiKey(default),none, oroauth; formodels.ymlcustom models,oauthis accepted by schema but does not waive theapiKeyrequirementtypeunderdiscovery:ollama,llama.cpp,lm-studio,openai-models-list,proxy, orlitellmtransport:pi-nativeonly. When set, every model under that provider is sent to anveyyon auth-gatewaycompatiblebaseUrlviaPOST /v1/pi/stream;apiKeyis the gateway bearer.
Validation rules (current)
Full custom provider (models is non-empty)
Required:
baseUrlapiKeyunlessauth: noneapiat provider level or each model
Override-only provider (models missing or empty)
Must define at least one of:
baseUrlapiKeyauth: noneheaderscompatdisableStrictToolsmodelOverridesdiscovery
Discovery
discoveryrequires provider-levelapi, exceptdiscovery.type: proxy(per-model wire auto-detected).
Model value checks
idrequiredcontextWindowandmaxTokensmust be positive if provided
Secret values
Provider apiKey values and provider/model headers values are resolved in this order:
- A value starting with
!runs as a shell command with a 10 s timeout and its trimmed stdout is used. Empty or failing commands are omitted. Successful outputs are cached for the process lifetime, so the command is not re-run for every model. ${NAME}or$NAMEreads the environment variableNAME.- A bare value shaped like an environment variable name (
DEEPSEEK_API_KEY, upper case, digits and underscores) reads that variable too. literal:<text>is<text>, verbatim, with no lookup.- Any other value is itself, which is how a key such as
sk-...orsk_live_...works.
providers:
openai:
apiKey: "!op read op://dev/openai/api-key"
headers:
X-Team-Key: "!bw get password veyyon-team-key"
gateway:
apiKey: ${GATEWAY_API_KEY}
headers:
X-Tenant: literal:ACME_TENANT
A variable named in cases 2 or 3 that is unset, or set to an empty string, resolves to nothing: the
key is not installed, no request contains it, and a warning states the variable and the setting.
Earlier versions used the variable’s own name as the value, so apiKey: DEEPSEK_API_KEY was sent to
the provider as the key. A key that is genuinely upper case text rather than a variable name is
written literal:MY_KEY.
Merge and override order
ModelRegistry pipeline (on refresh):
- Load built-in providers/models from
@veyyon/catalog(getBundledProviders/getBundledModels). - Load
models.yml/models.yamlcustom config. - Apply provider overrides (
baseUrl,headers,disableStrictTools) to built-in models. - Apply
modelOverrides(per provider + model id). - Merge custom
models:- same
provider + idreplaces existing - otherwise append
- same
- Load cached/runtime-discovered models (Ollama, llama.cpp, LM Studio, plus built-in provider managers), then re-apply model overrides.
Provider-model cache and static fingerprint
Cached per-provider model lists are persisted in the model-cache SQLite
database (current schema version 8) with a static_fingerprint column that
hashes the static catalog slice merged into the row. When resolveProviderModels
skips the network fetch and the fingerprint of the in-memory static
catalog matches the cached one, the cached rows are returned verbatim,
the static + dynamic merge is bypassed entirely. The fingerprint is
memoized per process by tagging the static-models array with a symbol
property, so repeated cold-start calls do not re-hash.
Canonical model equivalence and coalescing
The registry keeps every concrete provider model and then builds a canonical layer above them.
Canonical ids are official upstream ids only, for example:
claude-opus-4-6claude-haiku-4-5gpt-5.3-codex
models.yml equivalence config
Example:
providers:
zenmux:
baseUrl: https://api.zenmux.example/v1
apiKey: ZENMUX_API_KEY
api: openai-codex-responses
models:
- id: codex
name: Zenmux Codex
reasoning: true
input: [text]
cost:
input: 0
output: 0
cacheRead: 0
cacheWrite: 0
contextWindow: 200000
maxTokens: 32768
equivalence:
overrides:
zenmux/codex: gpt-5.3-codex
p-codex/codex: gpt-5.3-codex
exclude:
- demo/codex-preview
Build order for canonical grouping:
- exact user override from
equivalence.overrides - bundled official-id matches from built-in model metadata
- conservative heuristic normalization for gateway/provider variants
- fallback to the concrete model’s own id
Current heuristics are intentionally narrow:
- embedded upstream prefixes can be stripped when present, for example
anthropic/...oropenai/... - dotted and dashed version variants can normalize only when they map to an existing official id, for example
4.6 -> 4-6 - ambiguous families or versions are not merged without a bundled match or explicit override
Canonical resolution behavior
When multiple concrete variants share a canonical id, resolution uses:
- availability and auth
config.ymlmodelProviderOrder- existing registry/provider order if
modelProviderOrderis unset
Disabled or unauthenticated providers are skipped.
Session state and transcripts continue to record the concrete provider/model that actually executed the turn.
Provider defaults vs per-model overrides:
- Provider
headersare baseline. - Model
headersoverride provider header keys. modelOverridescan override model metadata (name,reasoning,thinking,input,supportsTools,cost,premiumMultiplier,contextWindow,maxTokens,omitMaxOutputTokens,headers,compat,contextPromotionTarget).compatis deep-merged for nested routing blocks (openRouterRouting,vercelGatewayRouting,extraBody).
Runtime discovery integration
Implicit Ollama discovery
If ollama is not explicitly configured, registry adds an implicit discoverable provider:
- provider:
ollama - api:
openai-responses - base URL:
OLLAMA_BASE_URL, orOLLAMA_HOST, orhttp://127.0.0.1:11434 - context window:
OLLAMA_CONTEXT_LENGTHif set, otherwise Ollama/api/showmetadata, otherwise128000 - auth mode: keyless (
auth: nonebehavior)
Runtime discovery calls Ollama endpoints and normalizes discovered OpenAI-compatible models to openai-responses.
OLLAMA_CONTEXT_LENGTH does not configure Ollama’s runtime num_ctx; set that in Ollama/model configuration separately.
Implicit llama.cpp discovery
If llama.cpp is not explicitly configured, registry adds an implicit discoverable provider:
- provider:
llama.cpp - api:
openai-responses - base URL:
LLAMA_CPP_BASE_URLorhttp://127.0.0.1:8080 - auth mode: keyless (
auth: nonebehavior)
Runtime discovery calls llama.cpp model endpoints and synthesizes model entries with local defaults.
Implicit LM Studio discovery
If lm-studio is not explicitly configured, registry adds an implicit discoverable provider:
- provider:
lm-studio - api:
openai-completions - base URL:
LM_STUDIO_BASE_URLorhttp://127.0.0.1:1234/v1 - auth mode: keyless (
auth: nonebehavior)
Runtime discovery fetches models (GET /models) and synthesizes model entries with local defaults.
This path also works for local OpenAI-compatible servers that are not LM Studio. For example, if oMLX is bound to Ollama’s usual port, set LM_STUDIO_BASE_URL=http://127.0.0.1:11434/v1 to discover it through the existing /v1/models flow. Running oMLX and Ollama side by side requires assigning a different port to one of them. Do not configure oMLX as ollama: Ollama discovery uses native /api/tags and /api/show endpoints, not OpenAI /v1/models.
LiteLLM provider discovery
When litellm is active (for example through LITELLM_API_KEY or stored auth), runtime discovery uses the LiteLLM proxy:
- provider:
litellm - api:
openai-completions - base URL: explicit provider
baseUrl/models.ymlconfig, otherwiseLITELLM_BASE_URL, otherwisehttp://localhost:4000/v1 - auth mode:
LITELLM_API_KEYor stored LiteLLM auth when the proxy requires a key
Runtime discovery probes LiteLLM management metadata first: GET /model_group/info, then GET /v2/model/info, then falls back to the OpenAI-compatible GET /models list. Rich metadata maps max_input_tokens, max_output_tokens, supports_vision, and supports_reasoning; bare fallback ids are enriched against bundled reference metadata when available.
Explicit provider discovery
You can configure discovery yourself:
providers:
ollama:
baseUrl: http://127.0.0.1:11434
api: openai-responses
auth: none
discovery:
type: ollama
llama.cpp:
baseUrl: http://127.0.0.1:8080
api: openai-responses
auth: none
discovery:
type: llama.cpp
Custom LiteLLM gateways can use the same rich discovery path:
providers:
litellm-gateway:
baseUrl: http://gateway.example:4000/v1
apiKey: LITELLM_API_KEY
api: openai-completions
discovery:
type: litellm
LiteLLM metadata endpoints use the configured base URL with a trailing v1 segment stripped for discovery only, preserving any preceding proxy path. Runtime model calls keep the configured OpenAI-compatible base URL, v1 segment included.
Proxy discovery (discovery.type: proxy)
For Anthropic+OpenAI-compatible proxies (new-api / one-api / similar)
that expose both /v1/messages and /v1/chat/completions behind the same
host. Discovery hits GET /v1/models (10s timeout, OpenAI-style payload) and
derives each model’s api from the entry’s supported_endpoint_types:
- contains
"anthropic"->api: anthropic-messages(routes via/v1/messages) - contains
"openai"->api: openai-completions(routes via/v1/chat/completions) - otherwise -> falls back to provider-level
apiif set, else dropped
Provider-level api is optional with discovery.type: proxy because the
per-model wire is auto-detected. The Anthropic SDK strips a trailing v1 segment
from baseUrl before appending its own v1/messages path, so a single discovery
baseUrl ending in v1 round-trips correctly to both wires.
providers:
newapi-reseller:
baseUrl: https://api.example.com/v1
apiKey: xxxx
authHeader: true # injects Authorization: Bearer for openai models
disableStrictTools: true # most anthropic-fronted proxies reject `strict`
discovery:
type: proxy
Extension provider registration
Extensions can register providers at runtime (pi.registerProvider(...)), including:
- model replacement/append for a provider
- custom stream handler registration for new API IDs
- custom OAuth provider registration
Auth and API key resolution order
When requesting a key for a provider, effective order is:
- Runtime override (CLI
--api-key) - Stored API key credential in
agent.db - Stored OAuth credential in
agent.db(with refresh) - Environment variable mapping (
OPENAI_API_KEY,ANTHROPIC_API_KEY, etc.) - ModelRegistry fallback resolver (provider
apiKeyfrommodels.yml, env-name-or-literal semantics)
models.yml apiKey behavior:
- Value is first treated as an environment variable name.
- If no env var exists, the literal string is used as the token.
If authHeader: true and provider apiKey is set, models get:
Authorization: Bearer <resolved-key>header injected.
Keyless providers:
- Providers marked
auth: noneare treated as available without credentials. getApiKey*returnskNoAuthfor them.
Broker mode
When VEYYON_AUTH_BROKER_URL (or auth.broker.url) is set, the local SQLite credential store is replaced by RemoteAuthCredentialStore. Layers 2 and 3 above (stored API key / OAuth in agent.db) are served from a broker-supplied snapshot whose refresh tokens are redacted; expiry triggers POST /v1/credential/:id/refresh on the broker rather than a local refresh.
AuthStorage.setConfigApiKey lets a models.yml apiKey win over a broker-resolved OAuth token without overriding a runtime --api-key. See auth-broker-gateway.md for the full broker / gateway design and env surface (VEYYON_AUTH_BROKER_URL, VEYYON_AUTH_BROKER_TOKEN, auth.broker.url, auth.broker.token).
Model availability vs all models
getAll()returns the loaded model registry (built-in + merged custom + discovered).getAvailable()filters to models that are keyless or have resolvable auth.
So a model can exist in registry but not be selectable until auth is available.
Runtime model resolution
CLI and pattern parsing
model-resolver.ts supports:
- exact
provider/modelId - exact canonical model id
- exact model id (provider inferred)
- fuzzy/substring matching
- glob scope patterns in
--models(e.g.openai/*,*sonnet*) - optional
:thinkingLevelsuffix (off|minimal|low|medium|high|xhigh|max)
--provider is legacy; --model is preferred.
Resolution precedence for exact selectors:
- exact
provider/modelIdbypasses coalescing - exact canonical id resolves through the canonical index
- exact bare concrete id still works
- fuzzy and glob matching run after the exact paths
Initial model selection priority
buildSessionOptions(...) in main.ts sets the model a session starts on, in this order:
- an explicit
--model(or the legacy--providerpair). A pattern that matches nothing is fatal, except for a bare id with no provider and no:suffix, which is carried asoptions.modelPatternand resolved again after extensions load, since an extension may register the provider it names. - the scoped set from
--models, when this is not a--continueor--resume. Inside that set the rememberedmodelRoles.defaultwins if it is there; if it is configured but unavailable,fallbackForUnavailableDefaultsubstitutes and prints the reason; otherwise the first scoped model is used. - otherwise nothing is pinned here, and the session resolves
modelRoles.defaultthroughresolveModelRoleValueagainst the models that have a usable credential.
A resumed session restores its own model rather than taking a CLI default, which is why step 2 is skipped under --continue/--resume.
This used to name findInitialModel(...), a function in config/model-resolver.ts with a different precedence. Nothing called it: main.ts had grown its own resolution and the two had drifted, so the documented order was one no session ever took. The dead copy is gone.
Role aliases and settings
Built-in role ids (see model-roles.ts):
| Role | Selectable in UI | Purpose |
|---|---|---|
default | No (hidden) | Storage key for the interactive model (/model persist / “set as default”) |
smol | Yes | Fast / cheap (--smol) |
slow | Yes | Thinking (--slow) |
vision | Yes | Multimodal |
plan | Yes | Plan mode (--plan) |
designer | Yes | Design-oriented work |
commit | Yes | Commit / changelog |
tiny | Yes | Lightweight background (titles, classifiers); else @smol |
advisor | Yes | Advisor runtime |
There is no task model role. Profile-wide and per-agent subagent model policy lives under subagent instead of the role table.
cycleOrder defaults to ["smol","slow"]; the entry default is stripped on load. Role aliases like @smol expand through settings.modelRoles; * selects @default (interactive). Quote @ aliases in YAML values (fable: "@slow"). Each role value can append a thinking selector (:minimal, :low, :medium, :high, :xhigh, :max).
If a role points at another role, the target model still inherits normally and any explicit suffix on the referring role wins for that role-specific use.
Related settings:
modelRoles(record)enabledModels(scoped pattern list)modelProviderOrder(global canonical-provider precedence)providers.kimiApiFormat(openaioranthropicrequest format)providers.openaiWebsockets(auto|off|onwebsocket preference for OpenAI Codex transport)
modelRoles may store either:
provider/modelIdto pin a concrete provider variant- a canonical id such as
gpt-5.3-codexto allow provider coalescing
For enabledModels and CLI --models:
- exact canonical ids expand to all concrete variants in that canonical group
- explicit
provider/modelIdentries stay exact - globs and fuzzy matches still operate on concrete models
Global enabledModels and disabledProviders entries may also be scoped to a path prefix:
enabledModels:
- claude-sonnet-4-5
- path: ~/work
models:
- anthropic/claude-opus-4-5
disabledProviders:
- ollama
- path: ~/private
providers:
- anthropic
String entries apply everywhere. Scoped entries apply when the current working directory is the configured path or one of its subdirectories. Use path, paths, pathPrefix, or pathPrefixes; use models for enabledModels, providers for disabledProviders, or values for either.
/model and veyyon models
$ veyyon models refresh google-antigravity --json
This command bypasses the cache for google-antigravity only, then prints that provider’s refreshed models. Omit the provider to refresh every authenticated or local provider.
Both surfaces keep provider-prefixed models visible and selectable.
They now also expose canonical/coalesced models:
/modelincludes a canonical view alongside provider tabsveyyon modelsprints provider-grouped tables of every concrete model (ls,find, andrefreshactions)
Selecting a canonical entry stores the canonical selector. Selecting a provider row stores the explicit provider/modelId.
Context promotion (model-level fallback chains)
Context promotion is an overflow recovery mechanism for small-context variants (for example *-spark) that automatically promotes to a larger-context sibling when the API rejects a request with a context length error.
Trigger and order
When a turn fails with a context overflow error (e.g. context_length_exceeded), AgentSession attempts promotion before falling back to compaction:
- If
contextPromotion.enabledis true, resolve a promotion target (see below). - If a target is found, switch to it and retry the request: no compaction needed.
- If no target is available, fall through to auto-compaction on the current model.
Target selection
Selection is explicit and model-driven:
currentModel.contextPromotionTarget(if configured)
Only the configured target is considered; context promotion does not automatically choose a larger same-provider/API sibling. Configured targets are ignored unless credentials resolve (ModelRegistry.getApiKey(...)).
OpenAI Codex websocket handoff
If switching from/to openai-codex-responses, session provider state key openai-codex-responses is closed before model switch. This drops websocket transport state so the next turn starts clean on the promoted model.
Persistence behavior
Promotion uses temporary switching (setModelTemporary):
- recorded as a temporary
model_changein session history - does not rewrite saved role mapping
Configuring explicit fallback chains
Configure fallback directly in model metadata via contextPromotionTarget.
contextPromotionTarget accepts either:
provider/model-id(explicit)model-id(resolved within current provider)
Example (models.yml) for an explicit OpenAI fallback:
providers:
openai-codex:
modelOverrides:
gpt-5.5:
contextPromotionTarget: openai-codex/gpt-5.4
The built-in model policy currently links OpenAI codex-spark variants to gpt-5.5, and gpt-5.5 to gpt-5.4, when that target exists on the same provider/API.
Compatibility and routing fields
The compat block on a provider or model overrides the URL-based auto-detection in packages/catalog/src/compat/openai.ts (buildOpenAICompat). It is validated by OpenAICompatSchema in packages/coding-agent/src/config/models-config-schema.ts and consumed by every openai-completions transport (packages/ai/src/providers/openai-completions.ts). The canonical type is OpenAICompat in packages/catalog/src/types.ts.
Endpoint-specific exceptions that interact with these fields are cataloged in Provider endpoint constraints.
models.yml accepts the following keys (all optional; unset falls back to URL detection):
Request shaping:
supportsStore: emitstore: falseon requests. Default: auto (off for non-standard endpoints).supportsDeveloperRole: use thedevelopersystem role for reasoning models instead ofsystem. Default: auto.supportsMultipleSystemMessages: preserve separate leading system/developer messages instead of coalescing them. Default: auto (known OpenAI-compatible hosted APIs preserve; strict-template/local hosts coalesce).supportsUsageInStreaming: sendstream_options: { include_usage: true }to receive token usage on streaming responses. Default:true.maxTokensField:"max_completion_tokens"or"max_tokens". Default: auto.supportsToolChoice: emit thetool_choiceparameter when the caller forces a specific tool. Default:true. Setfalsefor endpoints that 400 ontool_choice(e.g. DeepSeek when reasoning is on).supportsForcedToolChoice: accept a forcedtool_choicethat requires a specific tool. Default:true. Whenfalse, a forced selector is downgraded toautoso the tool stays available for endpoints that reject forced tool calls (e.g. some thinking-required OpenAI-compatible models).disableReasoningOnForcedToolChoice: dropreasoning_effort/ OpenRouterreasoningwhenevertool_choiceforces a call. Default: auto (Kimi/Anthropic-fronted endpoints).disableReasoningOnToolChoice: drop reasoning fields whenever anytool_choiceis sent. Default: auto (DeepSeek reasoning models).alwaysSendMaxTokens: always send a max-token field when the caller did not provide one. Default: auto (Kimi-family models derive TPM limits frommax_tokens).strictResponsesPairing: Responses-API tool-call/result history must be strictly paired. Default: auto (Azure OpenAI, GitHub Copilot).streamIdleTimeoutMs: stream-watchdog idle-timeout floor in ms for slow reasoning hosts. Default: auto (GLM coding-plan hosts, direct DeepSeek reasoning).cacheControlFormat:"anthropic"to include Anthropic-style prompt-cache markers in chat-completions payloads. Default: auto (OpenRouteranthropic/*models).supportsLongPromptCacheRetention: host honorsprompt_cache_retention: "24h"on the Responses API. Default: auto (api.openai.com).extraBody: extra top-level fields merged into every request body (gateway hints, controller selectors, etc.).
Reasoning / thinking:
supportsReasoningEffort: acceptreasoning_effort. Default: auto (off for Grok, Z.ai/Zhipu, and Xiaomi MiMo).supportsReasoningParams: whether request shaping may send reasoning params at all. Default: auto (off for GitHub Copilot chat-completions).reasoningEffortMap: partial map from internal effort levels (minimal|low|medium|high|xhigh|max) to provider-specific strings (e.g. Fireworks GLM mapsminimal -> "none"). Every key must name a level; a key that does not, such as a misspelledhihg, fails config validation and is reported by name. It used to be accepted and then never matched, so the remap silently did not happen and the level went to the provider unchanged.thinkingFormat: request shape for thinking:"openai"(reasoning_effort),"openrouter"(reasoning: { effort }),"zai"(thinking: { type: "enabled" }),"qwen"(top-levelenable_thinking), or"qwen-chat-template"(chat_template_kwargs.enable_thinking). Default:"openai".reasoningContentField: assistant field carrying chain-of-thought:"reasoning_content","reasoning", or"reasoning_text". Default: auto.requiresReasoningContentForToolCalls: assistant tool-call turns must round-trip the reasoning field (DeepSeek-R1, Kimi, OpenRouter when reasoning is on). Default:false.allowsSyntheticReasoningContentForToolCalls: allow a placeholder reasoning field when a prior assistant tool-call turn lacks provider reasoning content. Default:true; setfalsefor providers that validate the exact reasoning value.requiresAssistantContentForToolCalls: assistant tool-call turns must include non-empty text content (Kimi). Default:false.whenThinking: partial compat overrides applied only when a request actually engages thinking mode (deep-merged over the baseline compat).
Tool / message normalization:
requiresToolResultName: tool-result messages need anamefield (Mistral). Default: auto.requiresAssistantAfterToolResult: a user message after a tool result needs an assistant turn in between. Default: auto.requiresThinkingAsText: convert thinking blocks to text wrapped in<thinking>delimiters (Mistral). Default: auto.requiresMistralToolIds: normalize tool-call ids to exactly 9 alphanumeric chars. Default: auto.supportsStrictMode: accept the per-toolstrictfield on tool schemas. Default: conservative auto-detect per provider/baseUrl.toolStrictMode:"all_strict"forces strict on every tool,"none"forces it off; unset keeps the existing per-tool mixed behavior.
Gateway routing (only applied when baseUrl matches the gateway):
openRouterRouting.only/openRouterRouting.order: provider routing onopenrouter.ai(see https://openrouter.ai/docs/provider-routing).vercelGatewayRouting.only/vercelGatewayRouting.order: provider routing onai-gateway.vercel.sh(see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options).
Provider-level compat is the baseline; per-model compat is deep-merged on top, with openRouterRouting, vercelGatewayRouting, and extraBody merged as nested objects.
Anthropic compatibility (anthropic-messages)
For anthropic-messages models the runtime uses a separate AnthropicCompat shape (packages/catalog/src/types.ts). The models.yml schema exposes the strict-tools opt-out as a top-level provider field (see below) plus two Anthropic-side flags in the same compat slot, requiresToolResultId (non-standard id alias on tool_result blocks for Z.AI-style proxies) and replayUnsignedThinking (replay unsigned thinking blocks as native thinking instead of demoting them to text); the remaining Anthropic-side knobs (disableAdaptiveThinking, supportsEagerToolInputStreaming, supportsLongCacheRetention, supportsMidConversationSystem, supportsForcedToolChoice, supportsSamplingParams, escapeBuiltinToolNames) are set by built-in catalog metadata and are not user-configurable from models.yml.
Strict tool schemas (disableStrictTools)
Anthropic’s API supports a strict field on tool definitions that forces the model to always follow the provided schema exactly. Veyyon enables it by default for a small allowlist of high-frequency built-in anthropic-messages tools (bash, python, edit, and search) whose schemas fit Anthropic’s strict grammar limits; other tools still send normalized schemas but omit strict.
Third-party providers that front the Anthropic API (AWS Bedrock, Azure, self-hosted proxies) do not always implement this field and will reject requests that include it. Set disableStrictTools: true at the provider level to opt out of strict mode for the allowlisted tools:
providers:
bedrock-anthropic:
baseUrl: https://bedrock-runtime.us-east-1.amazonaws.com/anthropic
apiKey: AWS_BEARER_TOKEN
api: anthropic-messages
disableStrictTools: true
models:
- id: claude-sonnet-4-20250514
name: Claude Sonnet 4 (Bedrock)
input: [text, image]
contextWindow: 200000
maxTokens: 16384
cost:
input: 3.00
output: 15.00
cacheRead: 0.30
cacheWrite: 3.75
disableStrictTools is a provider-level flag that applies to all models in the provider. It disables the Anthropic strict marker only for tools that Veyyon would otherwise mark strict; it does not change runtime tool argument validation. Veyyon can automatically retry without strict tools after Anthropic reports a strict-grammar-too-large error before the first streamed token, but proxies that reject the strict field for other reasons should set this flag explicitly.
Tool schemas going on the wire are normalized by the unified flow in
packages/ai/src/utils/schema/normalize.ts (Google/CCA/MCP dispatchers
plus the OpenAI strict-mode sanitize+enforce pipeline). See
ai-schema-normalize.md for the strict-mode
edge cases (local $ref inlining, single-item allOf collapse,
anyOf-wrapper description hoist, enum/const primitive-type inference)
and the per-provider dispatcher mapping.
Practical examples
Local OpenAI-compatible endpoint (no auth)
providers:
local-openai:
baseUrl: http://127.0.0.1:8000/v1
auth: none
api: openai-completions
models:
- id: Qwen/Qwen2.5-Coder-32B-Instruct
name: Qwen 2.5 Coder 32B (local)
For oMLX or another local OpenAI-compatible server with a discoverable /v1/models endpoint, prefer discovery instead of listing models by hand. Set api to the endpoint family your server actually exposes: openai-completions uses /v1/chat/completions; servers that expose /v1/responses need openai-responses instead.
providers:
omlx:
baseUrl: http://127.0.0.1:11434/v1
auth: none
api: openai-completions
discovery:
type: openai-models-list
The built-in vLLM provider can be pointed at a non-default endpoint without declaring a custom discovery type. Veyyon uses vLLM’s /v1/models metadata and preserves vLLM’s max_model_len field as the discovered context window.
providers:
vllm:
baseUrl: http://192.168.5.3:8085/v1
auth: none
For multiple vLLM endpoints, use arbitrary provider IDs with the generic OpenAI-compatible discovery path. Set auth: none for local no-auth servers or apiKey for authenticated ones. Generic discovery reads max_model_len first and then context_length as a generic OpenAI-compatible fallback.
providers:
vllm-fast:
baseUrl: http://host-a:8000/v1
auth: none
api: openai-completions
discovery:
type: openai-models-list
vllm-long:
baseUrl: http://host-b:8000/v1
auth: none
api: openai-completions
discovery:
type: openai-models-list
Hosted proxy with env-based key
providers:
anthropic-proxy:
baseUrl: https://proxy.example.com/anthropic
apiKey: ANTHROPIC_PROXY_API_KEY
api: anthropic-messages
authHeader: true
disableStrictTools: true # if the proxy doesn't support strict tool schemas
models:
- id: claude-sonnet-4-20250514
name: Claude Sonnet 4 (Proxy)
reasoning: true
input: [text, image]
Override built-in provider route + model metadata
providers:
openrouter:
baseUrl: https://my-proxy.example.com/v1
headers:
X-Team: platform
modelOverrides:
anthropic/claude-sonnet-4:
name: Sonnet 4 (Corp)
compat:
openRouterRouting:
only: [anthropic]
Legacy consumer caveat
Most model configuration now flows through models.yml / models.yaml via ModelRegistry. Explicit .json / .jsonc paths remain supported only when passed programmatically to ModelRegistry; the default user config prefers ~/.veyyon/profiles/default/agent/models.yml, then falls back to ~/.veyyon/profiles/default/agent/models.yaml.
Failure mode
If models.yml / models.yaml fails schema or validation checks:
- registry keeps operating with built-in models
- error is exposed via
ModelRegistry.getError()and surfaced in UI/notifications
Providers
Providers are the model backends veyyon can route requests to: Anthropic, OpenAI, Google Gemini, Groq, OpenRouter, Mistral, xAI, local engines like Ollama, hosted gateways, custom models.yml providers, and providers registered by extensions.
A provider is the account or backend namespace, such as anthropic, openai, google, or ollama. A model is a concrete model under that provider, selected as provider/model-id, such as anthropic/claude-opus-4-6. Disabling a provider removes every model under it from selection; if you only want to narrow individual models, use model settings instead.
For endpoint-specific request, reasoning, tool, stream, usage, and retry constraints, see Provider endpoint constraints. For model selection and the full models.yml schema, see Model and Provider Configuration. For config-file locations and merge precedence, see Settings. For credential storage and login flows in depth, see Secrets and credentials. For the complete environment-variable reference, see Environment variables. For the embedded tiny-model engineering record (title/memory/auto-thinking local models), see Local tiny models. For context-file discovery providers, see Context files.
When a provider is available
At startup the model registry assembles its catalog from four sources, in order:
- The bundled model catalog (every built-in provider and its known models).
- Custom provider and model entries from
~/.veyyon/profiles/default/agent/models.yml. - Runtime-discovered models for providers that support discovery (local engines and discovery-enabled gateways).
- Providers and models registered by extensions.
The registry can hold a model even when it is not currently selectable. A model becomes available only when both conditions hold:
- its provider ID is not in the effective
disabledProviderslist; and - the provider is either keyless (an implicit local provider, or a custom provider with
auth: none) or has resolvable credentials.
disabledProviders is checked before credentials. If a provider ID is disabled, no stored key, OAuth session, environment variable, .env entry, or models.yml apiKey will make it selectable, the provider’s models are dropped from availability regardless of credentials. Removing the ID from the effective list restores them.
Keyless local engines are a special case: ollama, llama.cpp, and lm-studio are treated as keyless when no key is configured, so their discovered models are selectable as soon as the engine answers, no login required. See Built-in local engines.
Credentials and precedence
When a provider needs an API key, veyyon resolves it in this order (first match wins):
- Runtime override: a key supplied for the current process, e.g. CLI
--api-key. Never persisted. models.ymlconfig key: anapiKeypinned on a custom provider, registered as a config-sourced bearer. This deliberately beats stored OAuth, so a key supplied for a custombaseUrl/gateway is honored instead of forwarding an upstream OAuth token the proxy would reject.- Stored API key: an API-key credential saved in the auth store.
- Stored OAuth credential: refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic, each organization counts as its own account: one email holding both a Team seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts.
- Provider environment variable: including values loaded from
.envfiles (see the env-var table). models.ymlfallback resolver: keys for custom providers not otherwise registered.
Stored credentials live in the auth store at ~/.veyyon/profiles/default/agent/agent.db for local auth (or the active profile’s agent.db), or in the configured auth-broker snapshot when running in broker mode. VEYYON_CODING_AGENT_DIR relocates the entire agent directory, and the auth store moves with it.
OAuth vs API key, and provider-scoped logins
Logins are provider-scoped: authenticating anthropic does not authenticate openai, and each provider tracks its own credentials. A disabled provider stays disabled even with valid stored auth.
Use the interactive slash commands inside a session:
/login: opens the OAuth/key selector./login <provider>jumps straight to one provider (e.g./login anthropic); for an OAuth flow that needs a pasted callback, run/login <redirect-url>to complete it./logout: opens the provider selector to remove stored credentials.
For headless or remote setups backed by a shared auth broker, the CLI exposes veyyon auth-broker login <provider> / veyyon auth-broker logout (and status, list, import, migrate). See Secrets and credentials for the broker model.
When a model has no credentials, veyyon prints the /login command and the provider’s environment variable.
Pinning a key in models.yml
A custom provider’s apiKey is resolved as environment-variable-name-or-literal: if the value matches an existing environment variable, that variable’s value is used; otherwise the string itself is the key. Prefixing the value with ! runs it as a shell command and uses the trimmed stdout (see Model and Provider Configuration for the full value syntax).
# ~/.veyyon/profiles/default/agent/models.yml
providers:
my-gateway:
baseUrl: https://gateway.example.com/v1
api: openai-completions
apiKey: MY_GATEWAY_API_KEY # reads this env var; unset means no key, not a literal
models:
- id: claude-sonnet
name: Claude Sonnet via Gateway
contextWindow: 200000
maxTokens: 8192
If authHeader: true is set on a custom provider, the resolved key is injected as an Authorization: Bearer <key> header on every request to that provider.
Environment variables and .env files
Each provider has one or more environment variables that supply a key when no stored credential exists. The table below is the verified provider → variable map; the full catalog is large, so it is split into core and additional providers. Providers reached only through OAuth (/login) or local keyless discovery are covered below the tables instead. OAuth-backed providers can also accept a token variable in addition to (or instead of) an API key.
Core providers
| Provider ID | Environment variable(s) |
|---|---|
anthropic | ANTHROPIC_OAUTH_TOKEN, then ANTHROPIC_API_KEY (Foundry mode prefers ANTHROPIC_FOUNDRY_API_KEY when CLAUDE_CODE_USE_FOUNDRY=true) |
openai | OPENAI_API_KEY |
openai-codex | OPENAI_CODEX_OAUTH_TOKEN |
google | GEMINI_API_KEY |
google-vertex | GOOGLE_CLOUD_API_KEY, or Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS + GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION) |
groq | GROQ_API_KEY |
openrouter | OPENROUTER_API_KEY |
mistral | MISTRAL_API_KEY |
xai | XAI_API_KEY |
xai-oauth | XAI_OAUTH_TOKEN, then XAI_API_KEY |
github-copilot | COPILOT_GITHUB_TOKEN |
cursor | CURSOR_ACCESS_TOKEN |
azure | AZURE_OPENAI_API_KEY |
amazon-bedrock | AWS_BEARER_TOKEN_BEDROCK, or AWS_PROFILE, or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY, or a web-identity (AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN) / ECS credential chain |
Additional hosted providers
| Provider ID | Environment variable(s) |
|---|---|
cerebras | CEREBRAS_API_KEY |
command-code | CMD_API_KEY, then COMMAND_CODE_API_KEY |
deepseek | DEEPSEEK_API_KEY |
fireworks | FIREWORKS_API_KEY |
together | TOGETHER_API_KEY |
nvidia | NVIDIA_API_KEY |
huggingface | HUGGINGFACE_HUB_TOKEN, then HF_TOKEN |
moonshot | MOONSHOT_API_KEY, then KIMI_API_KEY |
nanogpt | NANO_GPT_API_KEY |
novita | NOVITA_API_KEY |
venice | VENICE_API_KEY |
vercel-ai-gateway | AI_GATEWAY_API_KEY (also VERCEL_AI_GATEWAY_API_KEY for catalog discovery) |
cloudflare-ai-gateway | CLOUDFLARE_AI_GATEWAY_API_KEY |
litellm | LITELLM_API_KEY; optional LITELLM_BASE_URL for the proxy endpoint |
kilo | KILO_API_KEY |
zai | ZAI_API_KEY |
zenmux | ZENMUX_API_KEY |
zhipu-coding-plan | ZHIPU_API_KEY |
umans | UMANS_AI_CODING_PLAN_API_KEY |
qianfan | QIANFAN_API_KEY |
qwen-portal | QWEN_OAUTH_TOKEN, then QWEN_PORTAL_API_KEY |
synthetic | SYNTHETIC_API_KEY |
minimax | MINIMAX_API_KEY |
minimax-code | MINIMAX_CODE_API_KEY |
minimax-code-cn | MINIMAX_CODE_CN_API_KEY |
baseten | BASETEN_API_KEY |
coreweave | COREWEAVE_API_KEY, then WANDB_API_KEY |
devin | DEVIN_API_KEY |
nous-research | NOUS_API_KEY (headless fallback; prefer /login nous-research) |
gitlab-duo-agent | GITLAB_TOKEN |
sakana | SAKANA_API_KEY, then FUGU_API_KEY |
xiaomi-token-plan-ams | XIAOMI_TOKEN_PLAN_AMS_API_KEY |
xiaomi-token-plan-cn | XIAOMI_TOKEN_PLAN_CN_API_KEY |
xiaomi-token-plan-sgp | XIAOMI_TOKEN_PLAN_SGP_API_KEY |
alibaba-coding-plan | ALIBABA_CODING_PLAN_API_KEY |
aimlapi | AIMLAPI_API_KEY |
gitlab-duo | GITLAB_TOKEN |
opencode-zen, opencode-go | OPENCODE_API_KEY |
firepass | FIREPASS_API_KEY |
wafer-serverless | WAFER_SERVERLESS_API_KEY |
xiaomi | XIAOMI_API_KEY |
ollama-cloud | OLLAMA_CLOUD_API_KEY |
ollama | OLLAMA_API_KEY (optional; local discovery is keyless by default) |
vllm | VLLM_API_KEY (optional; local discovery is keyless by default) |
lm-studio | LM_STUDIO_API_KEY (optional; keyless by default) |
llama.cpp | LLAMA_CPP_API_KEY (only when the server requires auth) |
OAuth-backed providers such as anthropic, github-copilot, cursor, ollama-cloud, qwen-portal, kimi-code, nous-research, xai-oauth, wafer-serverless, google-gemini-cli, and google-antigravity are normally reached through /login rather than an environment variable. Nous Portal stores a durable refresh token and mints short-lived inference access tokens for requests and model discovery; NOUS_API_KEY remains available for explicit headless use.
Command Code uses https://api.commandcode.ai/provider/v1, defaults to moonshotai/Kimi-K2.7-Code, and issues keys at Command Code Provider. Nous Research uses https://inference-api.nousresearch.com/v1 and defaults to the tool-capable anthropic/claude-sonnet-4.6; authenticated discovery adds the current tool-capable chat catalog and excludes embedding, media-generation, and non-tool rows. Nous accepts either sign-in: /login nous-research runs the Portal device flow, and /login nous-research-api-key takes a key pasted from the Portal. Both store one credential under nous-research, so the model list and the account card show a single Nous account either way.
.env discovery and precedence
veyyon eagerly loads .env files into the process environment before any provider lookup. It reads four files and, for each variable, the highest-priority source that defines it wins. Effective precedence, high to low:
- The process environment inherited by
veyyon(already-set variables always win). <cwd>/.env<agentDir>/.env, by default~/.veyyon/profiles/default/agent/.env<configRoot>/.env, by default~/.veyyon/profiles/default/.env~/.env
Both <agentDir> and <configRoot> follow the active profile, so --profile work reads ~/.veyyon/profiles/work/agent/.env and ~/.veyyon/profiles/work/.env.
A variable already present in the process environment is never overwritten by a .env file. Among the files, a value set in <cwd>/.env wins over <agentDir>/.env, which wins over <configRoot>/.env, which wins over ~/.env. So a shell-exported OPENAI_API_KEY beats every .env file, and a project’s <cwd>/.env beats your home ~/.env.
The order does not depend on which part of veyyon runs first. ~/.env is applied before anything resolves a directory, because a VEYYON_CODING_AGENT_DIR or XDG_CONFIG_HOME set there determines where the other two files even are; the remaining layers are applied once those directories are known, and they override the values ~/.env contributed. Whichever module a program imports, it sees the same result.
Project-local .env is the simplest way to make one repository use a project-specific gateway, key, or local endpoint:
# <project>/.env
OPENROUTER_API_KEY=sk-or-...
OLLAMA_BASE_URL=http://127.0.0.1:11434
.env parsing is intentionally minimal:
- blank lines and lines starting with
#are ignored; - keys must match
[A-Za-z_][A-Za-z0-9_]*(shell-identifier shape): other names are dropped; - values may be wrapped in single or double quotes, which are stripped;
- values containing a NUL byte are dropped.
Built-in local engines
Three local engines are discovered automatically without needing a models.yml entry. Each uses a base URL that can be overridden by an environment variable:
| Provider ID | Base URL (env override → default) | Notes |
|---|---|---|
ollama | OLLAMA_BASE_URL, then OLLAMA_HOST (normalized), else http://127.0.0.1:11434 | Keyless by default. |
llama.cpp | LLAMA_CPP_BASE_URL, else http://127.0.0.1:8080 | Keyless unless a key is stored for llama.cpp. |
lm-studio | LM_STUDIO_BASE_URL, else http://127.0.0.1:1234/v1 | Keyless by default. |
These implicit engines are skipped when:
- a provider with the same ID is already configured in
models.yml(your explicit config wins); or - the provider ID appears in the effective
disabledProviderslist.
Install and run these engines with their own tooling (ollama serve, llama-server, LM Studio); Veyyon discovers a running endpoint automatically via the table above.
Disabling model providers
Use the disabledProviders setting to remove a provider’s models from selection:
# ~/.veyyon/profiles/default/agent/config.yml
disabledProviders:
- anthropic
- openai
- google
- groq
Provider IDs are matched exactly. Disable google to hide the Google Gemini API provider; the OAuth-backed Google providers google-gemini-cli and google-antigravity are separate IDs and must be disabled individually. Disable ollama, llama.cpp, or lm-studio to stop local discovery for that engine.
disabledProviders applies uniformly to:
- bundled catalog providers;
- custom
models.ymlproviders; - runtime-discovered provider models;
- extension-registered providers;
- implicit local engines.
Disabling a provider does not delete its stored credentials, re-enable it by removing its ID from the effective list.
Per-project provider control
A repository cannot carry settings: <project>/.veyyon/config.yml is not read. When one repository must allow or hide a different provider set than your profile default, use a path-scoped entry (below) or pass a --config overlay for that run:
$ veyyon --config ./no-openai.yml
Settings arrays are replaced wholesale by the higher-precedence layer, not merged or appended. If the profile file disables three providers and an overlay disables one, that process sees only the overlay list. If you want an overlay to add to the profile set, repeat the profile IDs in the overlay. See Settings for the full precedence chain, including --config overlays and runtime overrides.
Path-scoped disabledProviders
disabledProviders can mix plain string entries (apply everywhere) with path-scoped entries (apply only when the current working directory matches a configured path):
disabledProviders:
- ollama
- path: ~/projects/sensitive
providers:
- anthropic
- openai
- paths:
- ~/work/client-a
- ~/work/client-b
values:
- openrouter
- Bare string entries always apply.
- A scoped entry applies when the current working directory is the configured path or sits under it.
~expands to the home directory. - Accepted path keys:
path,paths,pathPrefix,pathPrefixes. - Accepted value keys:
providers,values,items.
For the example above:
ollamais disabled everywhere.anthropicandopenaiare additionally disabled under~/projects/sensitive.openrouteris additionally disabled under~/work/client-aand~/work/client-b.
Path scopes are resolved after the settings merge. Because a higher-precedence layer replaces the whole array, a project-level disabledProviders array drops any scoped entries that only existed in the global array. enabledModels is the only other setting that supports the same path-scoped form. See Settings for details.
Provider IDs vs discovery provider IDs
disabledProviders uses a single shared ID namespace that gates two different subsystems:
- Model providers: the backends on this page (
anthropic,openai,ollama, a custommodels.ymlID, …). Disabling one removes its models from selection. - Discovery providers: sources of context files, MCP servers, commands, skills, hooks, tools, prompts, and settings. Disabling one stops that source from contributing capability items.
| Entry type | Examples | Effect |
|---|---|---|
| Model provider ID | anthropic, openai, google, groq, openrouter, ollama, my-gateway | Removes that provider’s models from availability. |
| Discovery provider ID | native, claude, codex, gemini, agents, github | Stops that discovery source from contributing capability items. |
Watch the related names. The Google Gemini API models use the model provider ID google; gemini is a discovery provider ID (the source that reads GEMINI.md), not the Google model provider. Use discovery IDs only when you intend to disable an entire config source. See Context files for the discovery-provider side.
Custom providers in models.yml
Custom providers live in ~/.veyyon/profiles/default/agent/models.yml under providers:. A provider ID defined there participates in the same selection, credential resolution, and disabledProviders rules as built-in providers.
Minimal OpenAI-compatible provider:
providers:
my-openai-compatible:
baseUrl: https://api.example.com/v1
api: openai-completions
apiKey: MY_OPENAI_COMPATIBLE_KEY # env-var name; `literal:text` for verbatim text
models:
- id: fast-chat
name: Fast Chat
contextWindow: 128000
maxTokens: 8192
Keyless local provider (no credentials required):
providers:
local-proxy:
baseUrl: http://127.0.0.1:4000/v1
api: openai-completions
auth: none
models:
- id: local-model
name: Local Model
contextWindow: 32768
maxTokens: 4096
Discovery-enabled provider (models fetched from the endpoint at runtime):
providers:
team-proxy:
baseUrl: https://models.example.com/v1
apiKey: TEAM_PROXY_API_KEY
authHeader: true # send Authorization: Bearer <resolved key>
disableStrictTools: true
discovery:
type: proxy
For the full schema, all allowed api values, discovery types, model overrides, and equivalence settings, see Model and Provider Configuration.
To disable a custom provider, list its ID exactly:
disabledProviders:
- my-openai-compatible
- team-proxy
Troubleshooting
A provider’s models are not selectable. Confirm the provider has credentials (/login <provider>, an exported environment variable, or a models.yml apiKey) and that its ID is not in the effective disabledProviders list. Remember the rule: not disabled and (keyless or has credentials). Keyless local engines only appear once the engine is actually running and responding.
The wrong key is being used (a stale key from .env). Resolution favors runtime --api-key, then a models.yml config key, then stored credentials, then environment/.env. An already-set process environment variable also beats every .env file, and <cwd>/.env beats ~/.env. If an unexpected key wins, check for an exported shell variable and the four .env files in precedence order, and clear the one that should not apply.
A provider still appears even though I disabled it. disabledProviders arrays are replaced, not merged: a --config overlay array fully overrides the profile one. Verify the effective list for the directory you are in (path-scoped entries only apply at or under their configured path), and confirm the ID is spelled exactly. Use veyyon config get disabledProviders to inspect the merged value (see Settings).
A discovery provider name had no effect on models (or vice-versa). The ID namespace is shared. gemini, codex, claude, native, and agents are discovery-source IDs; the Google model backend is google. Make sure you are disabling the right kind of provider.
A custom models.yml provider does not load. A YAML or schema error makes the registry skip the custom file. Validate the file with veyyon models (use veyyon models find <substr> to scope it to one provider), confirm each provider has a baseUrl, a valid api, and at least one model entry, and that an implicit local engine is not silently shadowing it (an explicit ollama/lm-studio/llama.cpp entry replaces the built-in discovery for that ID). See Model and Provider Configuration.
MCP configuration in Veyyon
This guide explains how to add, edit, and validate MCP servers for the Veyyon coding agent.
Source of truth in code:
- Runtime config types:
packages/coding-agent/src/mcp/types.ts - Config writer:
packages/coding-agent/src/mcp/config-writer.ts - Loader + validation:
packages/coding-agent/src/mcp/config.ts - Capability providers (the editor configs Veyyon also reads):
packages/coding-agent/src/discovery/ - Schema:
packages/coding-agent/src/config/mcp-schema.json
Where MCP config lives
Veyyon-native MCP config lives in exactly one file, the active profile’s agent directory:
~/.veyyon/profiles/default/agent/mcp.json~/.veyyon/profiles/<name>/agent/mcp.jsonwhen a named profile is active (see Profiles)
The native provider also reads .mcp.json beside it for compatibility, but Veyyon writes to mcp.json.
There is no project scope, and no /mcp subcommand takes a scope at all. .veyyon/mcp.json, a root
mcp.json and a root .mcp.json inside a working tree used to be loaded and used to be writable
through /mcp add --scope project; none of them is read now, and neither the option spelling nor the
plain words project and user are accepted. Both are rejected with the reason, on the text surface
as well as in the terminal: the text handler kept the scope after the terminal dropped it, defaulted
to it, and wrote a file nothing loads while reporting success. A repository is content you may not
have written, so a checked-in file must not name a server the agent connects to or a command it
spawns. Veyyon still discovers servers from other tools’ user-level configs (~/.claude.json,
~/.claude/mcp.json, ~/.cursor/mcp.json, ~/.codex/config.toml, ~/.gemini/settings.json,
opencode, windsurf, and more), always from your home directory, never from a working tree, and
/mcp list shows the file each server came from.
One project-controlled route to an MCP server remains, and it is gated rather than removed: a
project plugin registry (.veyyon/plugins/installed_plugins.json) names plugin directories, and a
plugin may ship a .mcp.json. The registry is withheld until you approve it. See
Project trust.
Profiles
Named profiles (veyyon --profile <name>, the --alias shortcut, or VEYYON_PROFILE still work) isolate MCP config. When a profile is active, mcp.json resolves to that profile’s agent directory:
- Default profile:
~/.veyyon/profiles/default/agent/mcp.json - Profile
<name>:~/.veyyon/profiles/<name>/agent/mcp.json
Discovery, the /mcp commands, and the config writer all follow the active profile, so a profile sees only its own servers, never the default profile’s ~/.veyyon/profiles/default/agent/mcp.json. Add a server to a profile by launching under it (veyyon --profile <name>) and running /mcp add, or by editing ~/.veyyon/profiles/<name>/agent/mcp.json directly.
External-tool configs (.claude/, .cursor/, etc.) are profile-independent because they belong to those tools rather than to a Veyyon profile.
MCP follows the same profile rules as the rest of Veyyon-native config; see Configuration Discovery → Profiles.
Add a schema reference
Add this line at the top of the file for editor autocomplete and validation:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {}
}
Veyyon now writes this automatically when /mcp add, /mcp enable, /mcp disable, /mcp reauth, or other config-writing flows create or update a Veyyon-managed MCP file.
File shape
Veyyon supports this top-level structure:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"server-name": {
"type": "stdio",
"command": "npx",
"args": ["-y", "some-mcp-server"]
}
},
"disabledServers": ["server-name"]
}
Top-level keys:
$schema: optional JSON Schema URL for toolingmcpServers: map of server name to server configenabledServers: user-level list that overrides a discovered server’senabled: falseflag (for example when the source config is owned by another tool such asopencode.json);disabledServersstill winsdisabledServers: user-level denylist used to turn off discovered servers by name; runtime loading reads this list from the active profile’s user MCP file (~/.veyyon/profiles/default/agent/mcp.json, or~/.veyyon/profiles/<name>/agent/mcp.jsonunder a named profile)
Server names must match ^[a-zA-Z0-9_.-]{1,100}$.
Supported server fields
Shared fields for every transport:
enabled?: boolean: skip this server whenfalsetimeout?: number: MCP request timeout in milliseconds;0disables client-side MCP timeoutsauth?: { ... }: auth metadata used by Veyyon for OAuth/API-key flowsoauth?: { ... }: explicit OAuth client settings used during auth/reauth
Set VEYYON_MCP_TIMEOUT_MS=0 to disable the client-side timeout for every MCP server in the current process. Set it to a positive millisecond value, such as VEYYON_MCP_TIMEOUT_MS=120000, to apply one global timeout without editing each server entry.
stdio transport
stdio is the default when type is omitted.
Required:
command: string
Optional:
type?: "stdio"args?: string[]env?: Record<string, string>envPassthrough?: string[]— ambient variables to forward by nameinheritEnv?: boolean— forward the whole ambient environment, credentials includedcwd?: string
A stdio server receives a baseline of variables a program needs in order to run (PATH, HOME,
temp, locale, certificate and proxy settings, and version-manager directories; on Windows also
PATHEXT, SystemRoot, ComSpec and the ProgramFiles variants), plus env and the names
listed in envPassthrough. Every other ambient variable is withheld. inheritEnv: true disables
that bound for one server and logs a warning on each spawn. See
MCP setup.
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/Users/alice/projects",
"/Users/alice/Documents"
]
}
}
}
This follows the official Filesystem MCP server package (@modelcontextprotocol/server-filesystem).
http transport
Required:
type: "http"url: string
Optional:
headers?: Record<string, string>
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/"
}
}
}
This matches GitHub’s hosted GitHub MCP server endpoint.
sse transport
Required:
type: "sse"url: string
Optional:
headers?: Record<string, string>
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"legacy-remote": {
"type": "sse",
"url": "https://example.com/mcp/sse"
}
}
}
sse is still supported for compatibility, but the MCP spec now prefers Streamable HTTP (type: "http") for new servers.
Stopping a stdio server
A stdio server is usually started through a wrapper: npx, uvx, docker run, or a script
in a repository. The wrapper starts the real server as a child of its own, so the process
Veyyon spawned is not the process serving tools.
Ending a server ends the whole tree. Veyyon signals every live descendant and then the
process it spawned, waits 500 ms, and repeats the wave with a hard kill if anything is still
running. The second wave re-walks the tree, so a process started during the wait is included.
The wait after the hard kill is bounded at 1.5 s, so /mcp reload, a disconnect, and session
shutdown always return. The same teardown runs when a handshake fails, including a server that
never answers initialize.
The process group is signalled only when the server leads a group of its own. On Linux the server starts in a new session, so its group holds nothing else. On macOS it stays attached so the system can prompt for file access, and Windows has no process groups; there the group is Veyyon’s own and is left alone.
A server that daemonizes — double-forks into its own session — is outside this. It outlives the session that started it and has to be stopped by hand.
Auth fields
Veyyon understands two auth-related objects.
auth
{
"type": "oauth" | "apikey",
"credentialId": "optional-stored-credential-id",
"tokenUrl": "optional-token-endpoint",
"clientId": "optional-client-id",
"clientSecret": "optional-client-secret",
"resource": "optional-mcp-resource-uri"
}
Use this when Veyyon should remember how to rehydrate credentials for a server.
You normally do not need to write this block: when Veyyon completes an OAuth flow
for an http/sse server it stores the credential under a deterministic id
derived from the active profile and server URL
(mcp_oauth:profile:<profile>:<url>), with the refresh material embedded. Any
config that points at the same URL, including a definition-only entry with no
auth block at all, resolves the active profile’s own credential automatically,
including when auth storage is backed by a shared auth broker. An explicit
credentialId is still honored when it resolves; if it points at another
profile’s row, Veyyon falls back to the profile-scoped url-keyed binding.
/mcp reauth on a definition-only entry leaves the file untouched, the
credential (refresh material included) lives entirely in the active profile’s
auth storage (local agent.db or broker), so no config file ever picks up local
auth state. An explicitly configured Authorization header always wins over the
url-keyed binding.
The binding is per profile but not per project: once a profile has authorized a
URL, any config defining a server at that URL connects with that profile’s
credential automatically. That is one reason a repository cannot define an MCP
server: a checked-in entry stating an already-authorized URL would have borrowed
the profile’s credential. Servers you add through /mcp add are yours, and the
editor configs Veyyon still reads are named by file in /mcp list.
oauth
{
"clientId": "...",
"clientSecret": "...",
"redirectUri": "...",
"callbackPort": 3334,
"callbackPath": "/oauth/callback",
"prompt": "consent"
}
Use this when the MCP server requires explicit OAuth client settings.
prompt controls the OAuth prompt parameter sent with the authorization request. By default the parameter is omitted, matching the reference MCP SDK, except when the granted scopes include offline_access: OIDC Core requires prompt=consent to issue refresh-token access, so Veyyon sends consent for those requests. Without a consent prompt, a provider with an active browser session silently re-approves the same account, making it impossible to switch accounts or workspaces when reauthorizing (e.g. to use a different Linear workspace per Veyyon profile). Set it to "" to omit the parameter for providers that reject it, or to another value the provider understands (e.g. "select_account").
Slack is the clearest current example. Slack’s MCP server is hosted at https://mcp.slack.com/mcp, uses Streamable HTTP, and requires confidential OAuth with your Slack app’s client credentials.
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"slack": {
"type": "http",
"url": "https://mcp.slack.com/mcp",
"oauth": {
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
},
"auth": {
"type": "oauth",
"tokenUrl": "https://slack.com/api/oauth.v2.user.access",
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
}
}
}
}
Relevant Slack endpoints from Slack’s docs:
- MCP endpoint:
https://mcp.slack.com/mcp - Authorization endpoint:
https://slack.com/oauth/v2_user/authorize - Token endpoint:
https://slack.com/api/oauth.v2.user.access
Common copy-paste examples
Filesystem server via stdio
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/absolute/path/one",
"/absolute/path/two"
]
}
}
}
GitHub hosted server via HTTP
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/"
}
}
}
GitHub local server via Docker
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_PERSONAL_ACCESS_TOKEN"
}
}
}
}
This matches GitHub’s official local Docker image ghcr.io/github/github-mcp-server.
Slack hosted server via OAuth
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"slack": {
"type": "http",
"url": "https://mcp.slack.com/mcp",
"oauth": {
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
},
"auth": {
"type": "oauth",
"tokenUrl": "https://slack.com/api/oauth.v2.user.access",
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
}
}
}
}
Secrets and variable resolution
This is the part that usually trips people up.
Discovery-time ${...} expansion
Veyyon expands ${VAR} and ${VAR:-default} placeholders while discovering MCP configs from Veyyon-native files and standalone fallback files. Expansion applies recursively to string values in command, args, env, cwd, url, headers, auth, and oauth.
An unset variable with no default leaves the placeholder text in the value. In command, args, cwd, url and envPassthrough that text would become a program, an argument, a directory or a hostname, so the server is not started: the connection is refused with the field and the variable named, and nothing is spawned or dialled. env and headers are resolved again before connect and refuse the same way (below). In auth and oauth the placeholder is sent to the authorization server, which rejects the exchange.
A placeholder in a structural field is therefore resolved here or refused; it never reaches the server as text. To let a server read a variable itself, name it in env (or in envPassthrough) and read it from the process environment inside the server.
Example:
{
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}"
}
}
}
}
Pre-connect env/header resolution
Before Veyyon launches a stdio server or makes an HTTP/SSE request, it resolves stdio env values
and HTTP/SSE headers values like this:
- A value starting with
!runs as a shell command with a 10s timeout and its trimmed stdout is used. A command that fails, times out, or prints only whitespace omits that entry. ${NAME}or$NAMEreads the environment variableNAME.- A bare value shaped like an environment variable name (
GITHUB_TOKEN, upper case, digits and underscores) reads that variable too. literal:<text>is<text>, verbatim, with no lookup.- Any other value is itself.
A variable that is unset, or set to an empty string, resolves to nothing. The connection is not attempted and the error states the variable:
The header "Authorization" for https://api.example.com/mcp refers to the environment variable
GITHUB_TOKN, which is not set, so the connection was not attempted rather than sent with the
variable's own name as the value.
Earlier versions used the variable’s own name as the value in that case, so a typo was sent to the server as the credential and came back as the server’s opinion of a bad token.
Examples:
{
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
},
"headers": {
"X-MCP-Insiders": "true"
}
}
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"→ copy from the current shell environment, and refuse to connect when it is missing"Authorization": "Bearer hardcoded-token"→ use the literal value"X-Api-Key": "literal:PROJECT_KEY"→ sendPROJECT_KEYas the value, without looking it up"Authorization": "!printf 'Bearer %s' \"$GITHUB_TOKEN\""→ build the header from a command
When a command runs again
A command’s output is cached under the command text, so the same command is executed once per session however many servers and headers use it. Three events drop that cached output and run the command again:
- A request answered with 401 or 403. The retry uses the new value.
/mcp reconnect <name>, which re-reads the credentials of that server only./mcp reload, which re-reads the credentials of every configured MCP server. Commands used outside MCP config, such as a providerapiKey, keep their cached value.
An automatic reconnect after a dropped connection reuses the cached value. A lost connection indicates nothing about the credential, and re-running a password-manager command on every reconnect means an unlock prompt for each one.
A command that fails is not retried for 30 seconds, and an invalidation does not shorten that.
When a stored credential cannot be presented
A server authorized with OAuth keeps its credential in the profile’s credential store. When that
credential exists but cannot be used, Veyyon does not connect without it. The connection fails,
/mcp list shows the failure, and the message states the state and the command that fixes it:
- The credential was rejected and cleared. Run
/mcp reauth <name>. - The access token expired and the refresh token is held by the auth broker, which this process
cannot use. Run
/mcp reauth <name>to authorize again through the broker. - The credential store could not be read or renewed. Run
/mcp reconnect <name>to retry; the credential is left alone, because a store failure indicates nothing about it.
A refresh that fails while the access token is still valid keeps connecting with that token: the refresh runs up to five minutes before expiry, so the session still works.
A server with no stored credential connects as configured. Nothing was authorized, so an unauthenticated request is what the server sees, and its own answer is what Veyyon reports.
disabledServers
disabledServers is read from the user config file (~/.veyyon/profiles/default/agent/mcp.json) when a server is discovered from any source and you want Veyyon to ignore it without editing that other tool’s config.
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"disabledServers": ["github", "slack"]
}
/mcp add vs editing JSON directly
Use /mcp add when you want guided setup.
Use direct JSON editing when:
- you need a transport or auth option the wizard does not prompt for yet
- you want to paste a server definition from another MCP client
- you want schema-backed validation in your editor
After editing, use:
/mcp reloadto rediscover and reconnect servers in the current session/mcp listto see which config file a server came from/mcp test <name>to test a single server/mcp reconnect <name>to reconnect one server without rediscovering all configs/mcp resources,/mcp prompts, and/mcp notificationsto inspect non-tool MCP capabilities
Validation rules Veyyon enforces
From validateServerConfig() in packages/coding-agent/src/mcp/config.ts:
stdiorequirescommandhttpandsserequireurl- a server cannot set both
commandandurl - unknown
typevalues are rejected
Practical implications:
- Omitting
typemeansstdio - If you paste a remote server config and forget
"type": "http", Veyyon will treat it asstdioand complain thatcommandis missing sseremains valid for compatibility, but new hosted servers should usually be configured ashttp
Discovery and precedence
Veyyon does not merge duplicate server definitions across files. Discovery providers are prioritized, and the higher-priority definition wins. Separately, disabledServers from ~/.veyyon/profiles/default/agent/mcp.json can suppress a discovered server by name.
In practice:
- prefer
~/.veyyon/profiles/default/agent/mcp.jsonwhen you want a Veyyon-specific override - keep server names unique across tools when possible
- use
disabledServersin the user config when a third-party config keeps reintroducing a server you do not want
Troubleshooting
Server "name": stdio server requires "command" field
You probably omitted type: "http" on a remote server.
Server "name": both "command" and "url" are set
Pick one transport. Veyyon treats command as stdio and url as http/sse.
/mcp add worked but the server still does not connect
The JSON is valid, but the server may still be unreachable. Use /mcp test <name> and check whether:
- the binary or Docker image exists
- required environment variables are set
- the remote URL is reachable
- the OAuth or API token is valid
The server exists in another tool’s config but not in Veyyon
Run /mcp list: it shows the file each server came from. Veyyon discovers many third-party MCP files, but it never reads a repository’s own mcp.json, .mcp.json or .veyyon/mcp.json, and a disabledServers entry in your profile’s mcp.json can suppress a discovered server by name.
A call fails with a protocol error rather than a timeout
JSON-RPC lets a server answer with "id": null when it cannot tell which request an error belongs
to. A parse error is the usual case: the server could not read the request well enough to find its
id, so it has nothing to attribute the failure to.
Veyyon surfaces that answer instead of waiting. Every call in flight on that connection fails with the server’s own code and message, for example:
MCP error -32700: Parse error
The alternative would be to ignore a reply with no request id, and then every pending call sits until its timeout and reports that the server did not answer. That is the opposite of what happened: the server answered, and told you exactly what was wrong.
A -32700 means the bytes Veyyon sent were not valid JSON to that server, so report it with the
server name and the tool you called. It is a bug in the server or in the transport, not something a
config change fixes.
References
- MCP transport spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
- Filesystem server package: https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem
- GitHub MCP server: https://github.com/github/github-mcp-server
- Slack MCP server docs: https://docs.slack.dev/ai/slack-mcp-server/
Tool approval mode
Tool approval has two independent inputs:
- Tool declaration: every tool may declare an
approvaltier:read: reads data or updates UI-only session metadata.write: mutates workspace/session state but does not execute arbitrary code.exec: executes code, shells out, drives a browser, spawns agents, or performs similarly broad actions.
- User policy:
tools.approval.<toolName>: allow | deny | promptoverrides the mode for that tool unless a tool’s safety override forces a prompt. Adenyalways wins, on every rung. Anallowwins over acriticalsafety prompt onyolo, which is the escape hatch from the floor; belowyoloa safety override still prompts over it, because that is what makes the shippedautorung stop for a destructive command.
Tools without an approval declaration are treated as exec. This is the safe default for unknown custom tools. MCP server tools declare write.
Modes
Configure with tools.approvalMode:
| Mode | Auto-approves | Prompts for |
|---|---|---|
plan | read | write only inside an active plan-mode session; write and exec are otherwise denied |
ask | nothing | read, write, exec |
ask-command | read, write | exec |
auto (default) | read, write, exec | a per-tool policy, the working-directory boundary, credential use, and a tool’s own critical calls |
yolo | read, write, exec | none |
Under plan, exec is always denied (it returns an error to the model, never a prompt). Outside an active plan-mode session, write is denied too; with a plan-mode session active, write prompts.
Legacy aliases still accepted: always-ask → ask, write and auto-edit → ask-command.
--auto-approve and --yolo force tools.approvalMode: yolo for the session.
The working-directory boundary
The table above sorts tools by tier, and a tier does not determine which file a
call touches. The write tier auto-approves write in ask-command mode whether
the target is src/main.ts or /etc/hosts. The working-directory boundary is
the second question, asked after the tier:
Does this call touch a path outside the session working directory?
If it does, the call needs approval even when its tier would have allowed it.
This applies in plan, ask, ask-command and auto. It does not apply in yolo, which
opts out of all permission and so opts out of this too.
The boundary is physical, not textual. A path spelled entirely inside the working directory but reaching outside it through a symlink is still outside, and a path that cannot be resolved at all is treated as outside rather than assumed safe.
Every tool that reads or writes files takes part: read, write, edit,
ast_edit, search, inspect_image, and set_cwd.
set_cwd is on that list for a reason worth stating. It changes the working
directory, so an unbounded set_cwd would be a way to erase the boundary rather
than obey it: re-root to the parent, and every later write is inside the new root
by definition. Re-rooting outward therefore prompts, as a write outward
does. Re-rooting to a subdirectory does not prompt, because narrowing the working
directory reduces what the session can reach.
When there is no interactive UI, a call that needs approval fails rather than proceeding. The error leads with the specific path that crossed the boundary, so a headless or ACP run reports why it stopped and not merely that something needed a prompt.
The secret-use boundary
A tier does not determine whether a call is about to spend a credential either. The secret-use boundary is the third question, asked in the same modes as the second:
Do this call’s arguments carry a stored secret?
The model works with placeholders such as #GITHUB_TOKEN#, and Veyyon substitutes the real
value immediately before the tool runs, so the model can use a secret it never reads. That
substitution is recorded by secrets.auditLog, which answers the question afterwards. This
boundary is what prompts first: a call whose arguments carry a real credential needs approval
in plan, ask, ask-command and auto, and the prompt states the secret without showing its value.
yolo opts out of all permission and so opts out of this too. A call that mentions a
placeholder without expanding it, such as one made while secrets.enabled is false, carries
no credential and does not prompt.
The /yolo command (full session bypass)
The yolo mode above still honors your per-tool policies: tools.approval.<tool>: prompt and a tool’s own critical safety prompt both still stop the call. The /yolo command is stronger. It removes approval prompts for the current session, including per-tool prompt overrides and plain override prompts.
Run /yolo in the TUI and confirm the danger prompt to turn it on. While it is on, file writes, shell commands, and network calls run without a prompt. The composer border and prompt glyph turn red and the status line shows a red YOLO marker, so you always know it is active.
Three things still stop a call:
- an explicit
tools.approval.<tool>: deny, a hard denial rather than a prompt, - plan mode (mutating tools stay blocked), and
- a
criticalsafety decision, such as a command that would recursively delete your home directory. This one is a prompt, and it is the single prompt the bypass does not lift. Settools.approval.<tool>: allowif you want it gone too.
The bypass is session-scoped. It defaults to off, is never written to settings, and resets to off when the session ends. Turn it off at any time with /yolo off, and check the current state with /yolo status.
This is different from the --yolo and --auto-approve launch flags, which set the yolo approval mode (and so keep honoring your per-tool prompt/deny policies). The /yolo command is the in-session full bypass.
To start a session already in full bypass, pass --dangerously-skip-permissions. It turns on the same session-scoped bypass that /yolo on does (removing per-tool prompt overrides too), so explicit deny and plan mode still block, and you can toggle it off at runtime with /yolo off. Prefer --yolo/--auto-approve when you only want the yolo approval mode; reach for --dangerously-skip-permissions only when you want every prompt gone from the first tool call.
User overrides
tools.approval is honored in every mode:
tools:
approvalMode: ask-command
approval:
bash: prompt
read: allow
mcp__filesystem_delete: deny
Resolution per tool call:
- Compute the tool’s approval decision from
tool.approval(args); omitted meansexec. - Normalize
tools.approval.<tool>if the key is present.allow,denyandpromptare accepted in any case, with surrounding spaces trimmed. Any other value present under that key denies the tool, and a warning at startup states the setting, the value found and the accepted values. An absent key is unconfigured. - In
yolomode, the user policy is used when present. Otherwise acriticaldecision prompts and everything else is allowed: plainoverridereasons do not force a prompt inyolo, butcriticalones do. - In non-yolo modes, if the tool sets
override: true,denyis blocked and all other cases prompt, even if user policy isallow. - Otherwise, a valid user policy wins.
- Otherwise, the active mode auto-approves or prompts by tier.
A misspelled policy blocks the tool it names rather than being dropped. deny and not prompt,
because /yolo lifts a prompt: a typo would otherwise run the call in the mode where the policy
matters most. Only the named tool is affected; the rest of the record still applies. A
tools.approval that is not a per-tool record matches no tool, so it configures no policy at all
and the startup warning is the only sign of it.
Safety overrides
A tool can force a prompt with object-form approval:
approval: { tier: "exec", override: true, reason: "Needs confirmation" }
override: true beats a per-tool allow in plan, ask, ask-command, and auto. yolo ignores it.
There is a second strength for calls that must stop even there:
approval: { tier: "exec", critical: true, reason: "rm would recursively remove the home directory itself" }
critical: true implies override: true and adds a floor under it: the call still prompts in yolo, and the /yolo session bypass does not lift it. On yolo, setting tools.approval.<tool> explicitly wins in both directions, so allow is the escape hatch from the floor and deny is a hard block. Below yolo only the deny direction wins: an allow is outranked by the safety override, which is what makes the shipped auto rung stop for a destructive command it would otherwise run unasked.
bash splits its guard between the two strengths, by what a command does rather than by how it is detected. critical is destruction: the paths a command would recursively delete (judged after expansion, so rm -rf ~/ and rm -rf "$HOME"/ are recognized), a formatted filesystem, a raw device written over, a system account file overwritten, a delete running as root. override is a call that is dangerous without being irreversible: a script fetched from the network and piped into a shell, a host shutdown, a shell wired to a network socket. Both prompt in plan, ask, ask-command and auto; only the destructive half prompts in yolo.
A recursive delete is split the same way again, by whether the text makes the damage certain. A path the guard can settle is critical: a literal rm -rf /, a ~ or $HOME it resolved, a relative path that climbs out to the root. An expansion it cannot settle is judged by every dangerous value it could hold, and the reading that fired sets the strength. Reading the variable as EMPTY is critical, because an unset or misspelled name expands to nothing and that is its default state, so rm -rf "$OUT"/* and rm -rf "$D/lib" stop even in yolo. Reading it as / or as the home directory is an assumption about a value that does not exist, so a bare rm -rf "$D" is override: it prompts at every rung below yolo and no longer claims to be as certain as rm -rf /. Two things pull such a word back up to critical — the word spelling a protected component itself (rm -rf "$D/.ssh"), and a value that EXISTS which the guard rejected to paste, such as one that would word-split or glob (V="/*"), a ${VAR:-/} containing its own default, a shell-maintained $PWD, or another account’s ~user.
That split is the difference between yolo and auto. In yolo the operator has stopped being prompted, and a floor that catches curl -fsSL https://…/install.sh | sh catches an install somebody typed on purpose, which made the two rungs behave identically for the commands people reach for yolo to run. The floor is still there for the incident it exists for: tools.approvalMode defaults to auto, which runs the exec tier unasked, so without it the calls the guard considers most dangerous would be the ones most likely to run without a check.
Every flagged shape reports its own reason (“Formats a filesystem”, “Runs a script fetched from the network”), which surfaces as reason in the approval prompt. A shared “Critical pattern detected” named the mechanism rather than the risk, so the prompt reported that something in a list matched and nothing about what.
Per-tool prompt details
Tools can add approval-prompt body lines with formatApprovalDetails(args). The standard prompt includes:
Allow tool: <name>Origin: MCP server toolfor unannotatedmcp__...toolsReason: <reason>when the tool decision supplies one- tool-specific details such as command, path, code, browser action, or subagent assignment
Defining approval on tools
Built-in and custom tools share the same shape:
export type ToolTier = "read" | "write" | "exec";
export type ToolApprovalDecision = ToolTier | { tier: ToolTier; reason?: string; override?: boolean; critical?: boolean };
export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
approval?: ToolApproval;
formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
Examples:
approval: "read";
approval: (args) => (LSP_READONLY_ACTIONS.has(args.action) ? "read" : "write");
approval: (args) =>
destroysData(args.command)
? { tier: "exec", critical: true, reason: "Formats a filesystem" }
: fetchesAndRuns(args.command)
? { tier: "exec", override: true, reason: "Runs a script fetched from the network" }
: "exec";
ACP sessions
ACP (veyyon acp) uses the same settings resolver as normal Veyyon launches. The active profile’s config.yml applies, and any --config <file> overlays passed to the ACP server process apply to sessions created by that process.
To auto-approve ACP tool calls, set the mode in your profile config:
tools:
approvalMode: yolo
Or launch the ACP server with a runtime override or a one-process config overlay:
veyyon acp --yolo
veyyon acp --auto-approve
veyyon acp --approval-mode yolo
veyyon acp --config ./acp-yolo.yml # file contains tools.approvalMode: yolo
Precedence is the normal settings precedence: runtime flags (--approval-mode, --auto-approve, --yolo) override --config overlays, which override the profile config. ACP does not currently define a session/new, session/load, or session/resume approval-policy field, so ACP clients that need per-session yolo should launch a separate veyyon acp process with one of the flags above or with a session-specific --config overlay.
tools.approvalMode: yolo fully applies to ACP when it is explicitly configured or supplied by a runtime flag. It skips Veyyon’s approval prompts and also skips the ACP client permission gate for bash, edit, delete, and move unless tools.approval.<tool> is prompt or deny. The schema default is auto, not yolo, so default-config ACP sessions still keep the client permission gate; set tools.approvalMode: yolo explicitly when the client wants unattended execution.
When ACP approval is required, Veyyon routes it through the ACP client instead of the terminal TUI. Client-gated bash, edit, delete, and move calls use ACP session/request_permission; generic approval prompts use form elicitation when the client advertises elicitation.form. A rejected, cancelled, or unsupported prompt rejects/cancels the tool call; Veyyon does not silently allow it.
Subagents
A spawned subagent inherits the spawning session’s approval mode through its forked settings; nothing hardcodes a rung for it. The parent task approval is the authorization boundary for the delegation itself, and your tools.approval.<tool> policies apply inside the subagent exactly as they do in the parent. A subagent runs headless, so a call that would prompt fails with an error stating what needed approval rather than stalling on a UI that does not exist.
The /yolo bypass is the one part that is not a pure snapshot, and it moves in only one direction. A child is built with the bypass the parent held at spawn time, and isApprovalBypassed() then also consults the live parent on every check, so /yolo off in the parent reaches a subagent that is already running. It can only narrow: the child’s own spawn-time value is checked first, so a parent turning /yolo on mid-run cannot hand a bypass to a child that was spawned without one. Without the live read, revoking the bypass left every running subagent executing unasked with nothing on screen to say so.
Project trust
A repository can carry code that veyyon loads at startup: a plugin registry at
.veyyon/plugins/installed_plugins.json, and extension or hook files it names.
That code runs before tool approval applies. Opening a directory does not approve
it. Until you decide, it is withheld.
What is withheld
| Project file | What it grants |
|---|---|
.veyyon/plugins/installed_plugins.json | extensions, hooks, custom tools, slash commands and MCP servers, from the directories it names |
| an extension or hook file inside the project | module top-level code and its factory, at import |
A file outside the project root is not affected. Profile extensions, installed
plugins and paths you set in extensions: load as before — a configured path is
your own instruction and loads even when it lives inside the project, which is
where an extension is written while you are developing it. Settings come from
your profile and your home directory, so a repository cannot add itself to that
list.
Deciding
veyyon trust # show the code the project would run, and approve it
veyyon trust --list # show it without deciding
veyyon trust --deny # refuse, and remember the refusal
veyyon trust --forget # drop the decision
veyyon trust path/to/file.ts # decide one named file
Inside a session, /trust reports, and /trust approve, /trust deny and
/trust forget decide. /trust approve <path> approves one file by name, which
is how you answer a refusal that states a file the discovery scan does not list.
What a decision records
One sha-256 per approved file, keyed by the symlink-resolved project root, in
<agent dir>/project-trust.json. Consequences:
- A file that changes after you approved it is withheld again.
- A file that appears later was not approved by an earlier decision.
- Approving the plugin registry approves the plugins it names. Their install directories are usually outside the project, and their contents are not digested.
- A denial is stored, so the next launch neither loads the code nor prompts again.
- A store written by another version of veyyon, or one whose records are malformed, is discarded. Nothing is trusted and you are prompted again.
Reading a refusal
extensions: ext/hostile.ts was not loaded because this project has not been
trusted. Project code runs with your permissions; approve it with `/trust
approve` in this session or `veyyon trust` in this directory, or leave it
untrusted.
| Reason | Meaning |
|---|---|
| has not been trusted | no decision exists for this project |
| marked untrusted | you denied this project |
| changed since it was trusted | the file’s bytes differ from the approved ones |
| not part of the approved set | the file was not in the decision |
Refusals appear as startup warnings. Nothing prompts: a session that cannot prompt loads nothing rather than defaulting to yes.
Theming Reference
Theming in the coding agent: schema, loading, runtime behavior, and failure modes.
What the theme system controls
The theme system drives:
- foreground/background color tokens used across the TUI
- markdown styling adapters (
getMarkdownTheme()) - selector/editor/settings list adapters (
getSelectListTheme(),getEditorTheme(),getSettingsListTheme()) - symbol preset + symbol overrides (
unicode,nerd,ascii) - syntax highlighting colors used by native highlighter (
@veyyon/natives) - status line segment colors
Primary implementation: src/modes/theme/theme.ts.
Theme JSON shape
Theme files are JSON objects validated against the runtime schema in theme.ts (themeJsonSchema) and mirrored by src/modes/theme/theme-schema.json.
Top-level fields:
name(required)colors(required; all color tokens required)vars(optional; reusable color variables)export(optional; HTML export colors)symbols(optional)preset(optional:unicode | nerd | ascii)overrides(optional: key/value overrides forSymbolKey)
Color values accept:
- hex string (
"#RRGGBB") - 256-color index (
0..255) - variable reference string (resolved through
vars) - empty string (
"") meaning terminal default (\x1b[39mfg,\x1b[49mbg)
Required color tokens (current)
All tokens below are required in colors.
Core text and borders (11)
accent, border, borderAccent, borderMuted, success, error, warning, muted, dim, text, thinkingText
Background blocks (7)
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg, statusLineBg
Message/tool text (5)
userMessageText, customMessageText, customMessageLabel, toolTitle, toolOutput
Markdown (10)
mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
Tool diff + syntax highlighting (12)
toolDiffAdded, toolDiffRemoved, toolDiffContext,
syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
Mode/thinking borders (8)
thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh, bashMode, pythonMode
Status line segment colors (13)
statusLineSep, statusLineModel, statusLinePath, statusLineGitClean, statusLineGitDirty, statusLineContext, statusLineSpend, statusLineStaged, statusLineDirty, statusLineUntracked, statusLineOutput, statusLineCost, statusLineSubagents
Optional tokens
Purpose accents (5, optional)
sessionAccent, modeAccent, shareAccent, infoAccent, matchHighlight
These color the “cool arc” of the design language: session identity segments use sessionAccent, mode labels (plan, vibe, goal, loop) use modeAccent, share and collab segments use shareAccent, informational callouts such as the welcome tip label use infoAccent, and fuzzy-filter hit characters in select lists use matchHighlight. A theme that omits them still loads: each falls back to a sensible existing token (sessionAccent and modeAccent to accent, shareAccent to link then accent, infoAccent to muted, matchHighlight to warning). The built-in titanium theme binds them to its Daybreak palette (teal, violet, indigo, rose, gold).
composerBg (optional)
The tonal ground painted under the composer’s input rows and the padding rows above and below them (the “quiet card”). The padding rows carry the same ground so the card has a visible body rather than a single tinted line.
A theme that omits composerBg gets an unpainted composer: the input area renders directly on the terminal’s own background. Inheriting statusLineBg here used to render the composer band as a gray slab on mismatched terminals, so the default is no paint. A theme that wants a painted composer card must set composerBg explicitly, and an explicit value is painted.
export section (optional)
Used for HTML export theming helpers:
export.pageBgexport.cardBgexport.infoBg
If omitted, export code derives defaults from resolved theme colors.
symbols section (optional)
symbols.presetsets a theme-level default symbol set.symbols.overridescan override individualSymbolKeyvalues.symbols.spinnerFramesoverrides the loading spinner frames. Accepts either a flatstring[](applied to thestatusandactivityspinners) or an object{ "status"?: string[], "activity"?: string[], "thinking"?: string[] }to override each type independently. Any type not specified falls back to the symbol preset’s default frames.statusdrives the ~12.5fps spinner used by loaders and tool-execution indicators;activitydrives the ~30fps spinner used by markdown progress bars and similar high-frequency UI;thinkingdrives the starburst pulse shown in place of hidden reasoning while a model thinks (a single-frame array renders it static, with no animation timer).
Runtime precedence:
- settings
symbolPresetoverride (if set) - theme JSON
symbols.preset - fallback
"unicode"
Invalid override keys are ignored and logged (logger.debug).
Box-drawing borders
All outlined chrome, tool-result frames, overlays, code fences, the editor, the welcome banner, and markdown tables draw with the boxSharp.* tokens (┌┐└┘─│├┤┬┴┼).
Override behavior follows from that:
boxSharp.{topLeft,topRight,bottomLeft,bottomRight}restyle corners everywhere, markdown tables included.boxSharp.{horizontal,vertical}restyle edges and rules.boxSharp.{cross,teeDown,teeUp,teeRight,teeLeft}restyle dividers and junctions.- The
boxRound.*tokens (rounded corners╭╮╰╯) remain in the symbol schema, but no shipped surface consumes them today; setting them changes nothing.
Built-in vs custom theme sources
Theme lookup order (loadThemeJson):
- built-in embedded themes (
dark.json,light.json, and alldefaults/*.jsoncompiled intodefaultThemes) - custom theme file:
<customThemesDir>/<name>.json
Custom themes directory comes from getCustomThemesDir():
- default:
~/.veyyon/profiles/default/agent/themes: profile-aware: under a named profile (--profile <name>/VEYYON_PROFILE) this resolves to~/.veyyon/profiles/<name>/agent/themes - overridden by
VEYYON_CODING_AGENT_DIR:$VEYYON_CODING_AGENT_DIR/themes
getAvailableThemes() returns merged built-in + custom names, sorted, with built-ins taking precedence on name collision.
Loading, validation, and resolution
For custom theme files:
- read JSON
- parse JSON
- validate against
themeJsonSchema - resolve
varsreferences recursively - convert resolved values to ANSI by terminal capability mode
Validation behavior:
- missing required color tokens: explicit grouped error message
- bad token types/values: validation errors with JSON path
- unknown theme file:
Theme not found: <name>
Var reference behavior:
- supports nested references
- throws on missing variable reference
- throws on circular references
Terminal color mode behavior
Color mode detection (detectColorMode):
COLORTERM=truecolor|24bit=> truecolorWT_SESSION=> truecolorTERMindumb,linux, or empty => 256color- otherwise => truecolor
Conversion behavior:
- hex ->
Bun.color(..., "ansi-16m" | "ansi-256") - numeric ->
38;5/48;5ANSI ""-> default fg/bg reset
Runtime switching behavior
Initial theme (initTheme)
main.ts initializes theme with settings:
symbolPresetcolorBlindModetheme.darktheme.light
Auto theme slot selection uses terminal appearance in this order:
- terminal-reported OSC 11 background luminance, unless the macOS/Zellij fallback path is active
COLORFGBGbackground index (< 8=> dark,>= 8=> light)- macOS appearance fallback only for the known-broken macOS/Zellij OSC 11 path
- dark slot fallback
Current defaults from settings schema:
theme.dark = "titanium"theme.light = "light"symbolPreset = "unicode"colorBlindMode = false
Explicit switching (setTheme)
- loads selected theme
- updates global
themesingleton - optionally starts watcher
- triggers
onThemeChangecallback
On failure:
- falls back to built-in
dark - returns
{ success: false, error }
Preview switching (previewTheme)
- applies temporary preview theme to global
theme - does not change persisted settings by itself
- returns success/error without fallback replacement
Settings UI uses this for live preview and restores prior theme on cancel.
Watchers and live reload
When watcher is enabled (setTheme(..., true) / interactive init):
- watches
<customThemesDir>/<currentTheme>.jsononly when that file exists - built-ins are effectively not watched; built-in theme lookup also takes precedence over same-name custom files
- matching file changes schedule a debounced reload; reload errors or temporary file absence keep the last successfully loaded theme
- the watcher does not perform a delete/rename fallback; it waits for a future successful reload or explicit theme switch
Auto mode also reevaluates dark/light slot mapping from terminal appearance changes, SIGWINCH, and the macOS fallback observer when active.
Color-blind mode behavior
colorBlindMode changes only one token at runtime:
toolDiffAddedis HSV-adjusted (green shifted toward blue)- adjustment is applied only when resolved value is a hex string
Other tokens are unchanged.
Where theme settings are persisted
Theme-related settings are persisted by Settings to global config YAML:
- path:
<agentDir>/config.yml - default agent dir:
~/.veyyon/profiles/default/agent(profile-aware:~/.veyyon/profiles/<name>/agentunder a named profile) - effective default file:
~/.veyyon/profiles/default/agent/config.yml
Persisted keys:
theme.darktheme.lightsymbolPresetcolorBlindMode
Legacy migration exists: old flat theme: "name" is migrated to nested theme.dark or theme.light based on luminance detection.
Creating a custom theme (practical)
- Create file in custom themes dir, e.g.
~/.veyyon/profiles/default/agent/themes/my-theme.json. - Include
name, optionalvars, and all requiredcolorstokens. - Optionally include
symbolsandexport. - Select the theme in Settings (
Appearance -> Dark ThemeorAppearance -> Light Theme) depending on which auto slot you want.
Minimal skeleton:
{
"name": "my-theme",
"vars": {
"accent": "#7aa2f7",
"muted": 244
},
"colors": {
"accent": "accent",
"border": "#4c566a",
"borderAccent": "accent",
"borderMuted": "muted",
"success": "#9ece6a",
"error": "#f7768e",
"warning": "#e0af68",
"muted": "muted",
"dim": 240,
"text": "",
"thinkingText": "muted",
"selectedBg": "#2a2f45",
"userMessageBg": "#1f2335",
"userMessageText": "",
"customMessageBg": "#24283b",
"customMessageText": "",
"customMessageLabel": "accent",
"toolPendingBg": "#1f2335",
"toolSuccessBg": "#1f2d2a",
"toolErrorBg": "#2d1f2a",
"toolTitle": "",
"toolOutput": "muted",
"mdHeading": "accent",
"mdLink": "accent",
"mdLinkUrl": "muted",
"mdCode": "#c0caf5",
"mdCodeBlock": "#c0caf5",
"mdCodeBlockBorder": "muted",
"mdQuote": "muted",
"mdQuoteBorder": "muted",
"mdHr": "muted",
"mdListBullet": "accent",
"toolDiffAdded": "#9ece6a",
"toolDiffRemoved": "#f7768e",
"toolDiffContext": "muted",
"syntaxComment": "#565f89",
"syntaxKeyword": "#bb9af7",
"syntaxFunction": "#7aa2f7",
"syntaxVariable": "#c0caf5",
"syntaxString": "#9ece6a",
"syntaxNumber": "#ff9e64",
"syntaxType": "#2ac3de",
"syntaxOperator": "#89ddff",
"syntaxPunctuation": "#9aa5ce",
"thinkingOff": 240,
"thinkingMinimal": 244,
"thinkingLow": "#7aa2f7",
"thinkingMedium": "#2ac3de",
"thinkingHigh": "#bb9af7",
"thinkingXhigh": "#f7768e",
"bashMode": "#2ac3de",
"pythonMode": "#bb9af7",
"statusLineBg": "#16161e",
"statusLineSep": 240,
"statusLineModel": "#bb9af7",
"statusLinePath": "#7aa2f7",
"statusLineGitClean": "#9ece6a",
"statusLineGitDirty": "#e0af68",
"statusLineContext": "#2ac3de",
"statusLineSpend": "#7dcfff",
"statusLineStaged": "#9ece6a",
"statusLineDirty": "#e0af68",
"statusLineUntracked": "#f7768e",
"statusLineOutput": "#c0caf5",
"statusLineCost": "#ff9e64",
"statusLineSubagents": "#bb9af7"
}
}
Testing custom themes
Use this workflow:
- Start interactive mode (watcher enabled from startup).
- Open settings and preview theme values (live
previewTheme). - For custom theme files, edit the JSON while running and confirm auto-reload on save.
- Exercise critical surfaces:
- markdown rendering
- tool blocks (pending/success/error)
- diff rendering (added/removed/context)
- status line readability
- thinking level border changes
- bash/python mode border colors
- Validate both symbol presets if your theme depends on glyph width/appearance.
Real constraints and caveats
- All
colorstokens are required for custom themes. exportandsymbolsare optional.$schemain theme JSON is informational; runtime validation is enforced by an arktype schema in code.setThemefailure falls back todark;previewThemefailure does not replace current theme.- File watcher reload errors or temporary missing files keep the current loaded theme until a successful reload or explicit theme switch.
Hooks
Hook subsystem code lives under src/extensibility/hooks/*. Runtime loading uses the extension runner:
Runtime loading
--hookis treated as an alias for--extension(CLI paths are merged intoadditionalExtensionPaths)- JS/TS hook factories discovered through
hookCapability(for example~/.veyyon/profiles/<name>/agent/hooks/pre/*.ts; hooks are user-level only, a working tree’s.veyyon/hooks/is not read) are loaded as extension modules so theirpi.on(...)handlers bind to the runtime event bus - tools are wrapped by
ExtensionToolWrapper, notHookToolWrapper - context transforms and lifecycle emissions go through
ExtensionRunner
So this file documents the legacy hook subsystem implementation itself (types/loader/runner/wrapper), plus the factory shape still accepted when a discovered hook path is loaded by the extension runner.
Key files
src/extensibility/hooks/types.ts: hook context, event types, and result contractssrc/extensibility/hooks/loader.ts: module loading and hook discovery bridgesrc/extensibility/hooks/runner.ts: event dispatch, command lookup, error signalingsrc/extensibility/hooks/tool-wrapper.ts: pre/post tool interception wrappersrc/extensibility/hooks/index.ts: exports/re-exports
What a hook module is
A hook module must default-export a factory:
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function hook(pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (
event.toolName === "bash" &&
String(event.input.command ?? "").includes("rm -rf")
) {
return { block: true, reason: "blocked by policy" };
}
});
}
The factory can:
- register event handlers with
pi.on(...) - send persistent custom messages with
pi.sendMessage(...) - persist non-LLM state with
pi.appendEntry(...) - register slash commands via
pi.registerCommand(...) - register custom message renderers via
pi.registerMessageRenderer(...) - run shell commands via
pi.exec(...) - author schemas/helpers with injected
pi.zod,pi.typebox, and package exports viapi.pi
Discovery and loading
Default sessions load JS/TS hook factories discovered by hookCapability through the extension runner. discoverExtensionPaths(configuredPaths, cwd) does:
- Load native extension modules from the capability registry
- Load importable
.ts/.jshook factories from the hook capability registry - Append plugin extension entry points
- Append explicitly configured paths
The legacy discoverAndLoadHooks(configuredPaths, cwd) helper still exists and does:
- Load discovered hooks from capability registry (
loadCapability("hooks")) - Append explicitly configured paths (deduped by absolute path)
- Call
loadHooks(allPaths, cwd)
loadHooks then imports each path and expects a default function.
Path resolution
loader.ts resolves hook paths as:
- absolute path: used as-is
~path: expanded- relative path: resolved against
cwd
Event surfaces
Hook events are strongly typed in types.ts.
Session events
session_startsession_before_switch→ can return{ cancel?: boolean }session_switchsession_before_branch→ can return{ cancel?: boolean; skipConversationRestore?: boolean }session_branchsession_before_compact→ can return{ cancel?: boolean; compaction?: CompactionResult }session_compacting→ can return{ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }session_compactsession_before_tree→ can return{ cancel?: boolean; summary?: { summary: string; details?: unknown } }session_treesession_shutdown
Agent/context events
context→ can return{ messages?: Message[] }before_agent_start→ can return{ message?: { customType; content; display; details; attribution? } }agent_startagent_endturn_startturn_endauto_compaction_startauto_compaction_endauto_retry_startauto_retry_endttsr_triggeredtodo_reminder
Tool events (pre/post model)
tool_call(pre-execution) → can return{ block?: boolean; reason?: string }tool_result(post-execution) → can return{ content?; details?; isError? }
This is the hook subsystem’s core pre/post interception model.
Hook tool interception flow
tool_call handlers
│
├─ any { block: true }? ── yes ──> throw (tool blocked)
│
└─ no
│
▼
execute underlying tool
│
├─ success ──> tool_result handlers can override { content, details }
│
└─ error ──> emit tool_result(isError=true) then rethrow original error
Execution model and mutation semantics
1) Pre-execution: tool_call
HookToolWrapper.execute() emits tool_call before tool execution.
- if any handler returns
{ block: true }, execution stops - if handler throws, wrapper fails closed and blocks execution
- returned
reasonbecomes the thrown error text
2) Tool execution
Underlying tool executes normally if not blocked.
3) Post-execution: tool_result
After success, wrapper emits tool_result with:
toolName,toolCallId,inputcontentdetailsisError: false
If handler returns overrides:
contentcan replace result contentdetailscan replace result details
On tool failure, wrapper emits tool_result with isError: true and error text content, then rethrows original error.
What hooks can mutate
- LLM context for a single call via
context(messagesreplacement chain) - tool output content/details on successful tool calls (
tool_resultpath) - pre-agent injected message via
before_agent_start - cancellation/custom compaction/tree behavior via
session_before_*andsession_compacting
What hooks cannot mutate in this implementation
- raw tool input parameters in-place (only block/allow on
tool_call) - execution continuation after thrown tool errors (error path rethrows)
- final success/error status in wrapper behavior (returned
isErroris typed but not applied byHookToolWrapper)
Ordering and conflict behavior
Discovery-level ordering
Capability providers are priority-sorted (higher first). Dedupe is by capability key, first wins.
For hooks, capability key is ${type}:${tool}:${name}. Shadowed duplicates from lower-priority providers are marked and excluded from effective discovered list.
Load order
discoverAndLoadHooks builds a flat allPaths list, deduped by resolved absolute path, then loadHooks iterates in that order.
File order within each discovered directory depends on readdir output; the hook loader does not perform an additional sort.
Runtime handler order
Inside HookRunner, order is deterministic by registration sequence:
- hooks array order
- handler registration order per hook/event
Conflict behavior by event type:
tool_call: last returned result wins unless a handler blocks; first block short-circuitstool_result: last returned override wins (no short-circuit)context: chained; each handler receives prior handler’s message outputbefore_agent_start: first returned message is kept; later messages ignoredsession_before_*: latest returned result is tracked;cancel: trueshort-circuits immediatelysession_compacting: latest returned result wins
Command/renderer conflicts:
getCommand(name)returns first match across hooks (first loaded wins)getMessageRenderer(customType)returns first matchgetRegisteredCommands()returns all commands (no dedupe)
UI interactions (HookContext.ui)
HookUIContext includes:
select,confirm,input,editornotifysetStatuscustomsetEditorText,getEditorTextthemegetter
ctx includes hasUI, cwd, sessionManager, modelRegistry, current model, isIdle(), abort(), and hasQueuedMessages().
When running with no UI, the default no-op context behavior is:
select/input/editorreturnundefinedconfirmreturnsfalsenotify,setStatus,setEditorTextare no-opsgetEditorTextreturns""
Status line behavior
Hook status text set via ctx.ui.setStatus(key, text) is:
- stored per key
- sorted by key name
- sanitized (ANSI/VT escape sequences stripped; control characters mapped to spaces; repeated spaces collapsed; trimmed)
- joined and width-truncated for display
Error propagation and fallback
Load-time
- invalid module or missing default export → captured in
LoadHooksResult.errors - loading continues for other hooks
Event-time
HookRunner.emit(...) catches handler errors for most events and emits HookError to listeners (hookPath, event, error), then continues.
emitToolCall(...) is stricter: handler errors are not swallowed there; they propagate to caller. In HookToolWrapper, this blocks the tool call (fail-safe).
Realistic API examples
Block unsafe bash commands
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = String(event.input.command ?? "");
if (!cmd.includes("rm -rf")) return;
if (!ctx.hasUI) return { block: true, reason: "rm -rf blocked (no UI)" };
const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}`);
if (!ok) return { block: true, reason: "user denied command" };
});
}
Redact tool output on post-execution
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function (pi: HookAPI): void {
pi.on("tool_result", async (event) => {
if (event.toolName !== "read" || event.isError) return;
const redacted = event.content.map((chunk) => {
if (chunk.type !== "text") return chunk;
return {
...chunk,
text: chunk.text.replaceAll(/API_KEY=\S+/g, "API_KEY=[REDACTED]"),
};
});
return { content: redacted };
});
}
Modify model context per LLM call
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function (pi: HookAPI): void {
pi.on("context", async (event) => {
const filtered = event.messages.filter(
(msg) => !(msg.role === "custom" && msg.customType === "debug-only"),
);
return { messages: filtered };
});
}
Register slash command with command-safe context methods
import type { HookAPI } from "@veyyon/coding-agent/extensibility/hooks";
export default function (pi: HookAPI): void {
pi.registerCommand("handoff", {
description: "Create a new session with setup message",
handler: async (_args, ctx) => {
await ctx.waitForIdle();
await ctx.newSession({
parentSession: ctx.sessionManager.getSessionFile(),
setup: async (sm) => {
sm.appendMessage({
role: "user",
content: [
{ type: "text", text: "Continue from prior session summary." },
],
timestamp: Date.now(),
});
},
});
},
});
}
Export surface
src/extensibility/hooks/index.ts and the package subpath @veyyon/coding-agent/extensibility/hooks export:
- loading APIs (
discoverAndLoadHooks,loadHooks) - runner and wrapper (
HookRunner,HookToolWrapper) - all hook types
execCommandre-export
The package root (@veyyon/coding-agent) re-exports HookAPI and HookContext; the full hooks subsystem is additionally available from the hooks subpath.
Skills
Skills are file-backed capability packs discovered at startup and exposed to the model as:
- lightweight metadata in the system prompt (name + description)
- on-demand content via the
readtool againstskill://... - optional interactive
/skill:<name>commands
Implementation: src/extensibility/skills.ts, src/discovery/builtin.ts, src/internal-urls/skill-protocol.ts, src/discovery/agents-md.ts.
What a skill is in this codebase
A discovered skill is represented as:
namedescriptionfilePath(theSKILL.mdpath)baseDir(skill directory)- source metadata (
provider,level, path)
The runtime only requires name and path for validity. In practice, matching quality depends on description being meaningful.
Required layout and SKILL.md expectations
Directory layout
Skills are discovered as one level under skills/:
<skills-root>/<skill-name>/SKILL.md
Nested patterns like <skills-root>/group/<skill>/SKILL.md are not discovered.
Discovered layout (non-recursive under skills/):
<root>/skills/
├─ postgres/
│ └─ SKILL.md ✅ discovered
├─ pdf/
│ └─ SKILL.md ✅ discovered
└─ team/
└─ internal/
└─ SKILL.md ❌ not discovered (nested)
SKILL.md frontmatter
Supported frontmatter fields on the skill type:
name?: stringdescription?: stringglobs?: string[]alwaysApply?: booleanhide?: booleandisableModelInvocation?: boolean(Agent Skills equivalent ofhide; normalized from kebab-casedisable-model-invocation)- additional keys are preserved as unknown metadata
Current runtime behavior:
namedefaults to the skill directory namedescriptionis required for both providers that load skills ambiently:- native
.veyyonprovider (requireDescription: true), the profile’sskills/dir veyyon-pluginsextension-package skills (requireDescription: true)
- native
- the managed (auto-learn) provider also requires a description
Discovery pipeline
Skills load only from the active profile. loadSkills() passes an explicit
provider allowlist to loadCapability("skills"), so only the profile-native
providers run and no foreign-tool directory is ever scanned:
native(priority 100): the profile’s.../agent/skillsdir, user level only, viasrc/discovery/builtin.ts. Project-local.veyyon/skillsis deliberately not scanned.veyyon-plugins(priority 90):skills/bundled with plugins installed into the active profileveyyon-managed(priority 5): auto-learn skills under.../agent/managed-skills, discovered unconditionally (only writing/nudging is gated byautolearn.enabled); always defers to a same-named authored skill
The allowlist is defined by profileSkillProviderIds() in src/extensibility/skills.ts. If skills.enabled is false, discovery returns no skills.
Dedup key is skill name; the first item with a given name wins, and a same-named authored (native or veyyon-plugins) skill always beats the managed one.
Foreign providers are import-only
The claude, codex, agents, opencode, claude-plugins, and github
skill providers are still registered, but they are not in the ambient
allowlist, so they never contribute skills to a session. They exist to feed the
onboarding import scan (scanForeignConfig in src/discovery/import-scan.ts),
which enumerates user-level foreign skills so you can copy the ones you want into
the active profile. An imported skill becomes a profile-native native skill.
Filtering
Beyond the allowlist, loadSkills() applies these name-based controls:
disabledExtensionsentries withskill:<name>ignoredSkills(exclude; glob patterns)includeSkills(include allowlist; glob patterns; empty means include all)
Filter order is: not disabled by disabledExtensions, then not ignored, then included (if an include list is present). There are no per-source toggles.
Collision and duplicate handling
- Capability dedup already keeps the first skill per name (highest-precedence provider)
extensibility/skills.tsadditionally:- de-duplicates identical files by
realpath(symlink-safe) - emits collision warnings when a later skill name conflicts
- keeps the convenience
loadSkillsFromDir({ dir, source })API as a thin adapter overscanSkillsFromDir
- de-duplicates identical files by
Runtime usage behavior
System prompt exposure
System prompt construction (src/system-prompt.ts) uses discovered skills as follows:
- if
readtool is available:- include discovered skills list in prompt, excluding skills with
hide: true
- include discovered skills list in prompt, excluding skills with
- otherwise:
- omit discovered list
hide: true does not disable the skill. Hidden skills are still loaded and remain reachable through skill://<name> and /skill:<name> when skill commands are enabled.
Task tool subagents receive the session’s discovered/provided skills list via normal session creation; there is no per-task skill pinning override.
Interactive /skill:<name> commands
If skills.enableSkillCommands is true, interactive mode registers one slash command per discovered skill.
/skill:<name> [args] behavior:
- reads the skill file directly from
filePath - strips frontmatter
- injects skill body as a custom message
- delivery mode follows the submission keybinding:
- Enter → invokes the skill on the
steerqueue while streaming (matches free-text Enter, which also steers), or as a normal idle prompt when the agent is not streaming - Ctrl+Enter (
app.message.followUp) → invokes the skill on thefollowUpqueue while streaming, or as a normal idle prompt when the agent is not streaming
- Enter → invokes the skill on the
- appends metadata (
[Skill directory: <baseDir>], optionalUser: <args>)
There is no flag, mode-selector, or frontmatter knob to override this, the keybinding is the choice, identical to how free text is routed during streaming (Enter steers, Ctrl+Enter queues a follow-up; both dispatch through #invokeSkillCommand in packages/coding-agent/src/modes/controllers/input-controller.ts).
skill:// URL behavior
src/internal-urls/skill-protocol.ts supports:
skill://<name>→ resolves to that skill’sSKILL.mdskill://<name>/<relative-path>→ resolves inside that skill directory
skill:// URL resolution
skill://pdf
-> <pdf-base>/SKILL.md
skill://pdf/references/tables.md
-> <pdf-base>/references/tables.md
Guards:
- reject absolute paths
- reject `..` traversal
- reject any resolved path escaping <pdf-base>
Resolution details:
- skill name must match exactly
- relative paths are URL-decoded
- absolute paths are rejected
- path traversal (
..) is rejected - resolved path must remain within
baseDir - missing files return an explicit
File not founderror
Content type:
.md=>text/markdown.json=>application/json- everything else =>
text/plain
No fallback search is performed for missing assets.
Skills vs AGENTS.md, commands, tools, hooks
Skills vs AGENTS.md
- Skills: named, optional capability packs selected by task context or explicitly requested
- AGENTS.md/context files: persistent instruction files loaded as context-file capability and merged by level/depth rules
src/discovery/agents-md.ts specifically walks ancestor directories from cwd to discover standalone AGENTS.md files (stopping at the repo root, or home when no repo root is known), skipping files whose containing directory name starts with a dot.
Skills vs slash commands
- Skills: model-readable knowledge/workflow content
- Slash commands: user-invoked command entry points
/skill:<name>is a convenience wrapper that injects skill text; it does not change skill discovery semantics
Skills vs custom tools
- Skills: documentation/workflow content loaded through prompt context and
read - Custom tools: executable tool APIs callable by the model with schemas and runtime side effects
Skills vs hooks
- Skills: passive content
- Hooks: event-driven runtime interceptors that can block/modify behavior during execution
Practical authoring guidance tied to discovery logic
- Put each skill in its own directory:
<skills-root>/<skill-name>/SKILL.md - Always include explicit
nameanddescriptionfrontmatter - Keep referenced assets under the same skill directory and access with
skill://<name>/... - Put every skill in the active profile’s
skills/dir; there is no nested-taxonomy or custom-directory scanning - Avoid duplicate skill names; the first match wins by provider precedence (authored beats managed)
/tree Command Reference
/tree opens the interactive session tree navigator. Selecting an entry moves the active leaf in the current session file and continues from that point.
This is an in-file leaf move, not a new session export.
What /tree does
- Builds a tree from current session entries (
SessionManager.getTree()) - Opens
TreeSelectorComponentwith keyboard navigation, filters, and search - On selection, calls
AgentSession.navigateTree(targetId, { summarize, customInstructions }) - Rebuilds visible chat from the new leaf path
- Optionally prefills editor text when selecting a user/custom message
Primary implementation:
src/slash-commands/builtin-registry.ts(/tree,/branchcommand routing)src/modes/controllers/input-controller.ts(keybinding wiring, double-escape behavior)src/modes/controllers/selector-controller.ts(tree UI launch + summary prompt flow)src/modes/components/tree-selector.ts(navigation, filters, search, labels, rendering)src/session/agent-session.ts(navigateTreeleaf switching + optional summary)src/session/session-manager.ts(getTree,branch,branchWithSummary,resetLeaf, label persistence)
How to open it
Any of the following opens the same selector:
/tree- configured keybinding for the
app.session.treeaction - double-escape on empty editor when
doubleEscapeAction = "tree"(default) /branchwhendoubleEscapeAction = "tree"(routes to tree selector instead of user-only branch picker)
Tree UI model
The tree is rendered from session entry parent pointers (id / parentId).
- Children are sorted by timestamp ascending (older first, newer lower)
- Active branch (path from root to current leaf) is marked with a bullet
- Labels (if present) render as
[label]before node text - If multiple roots exist (orphaned/broken parent chains), they are shown under a virtual branching root
Example tree view (active path marked with •):
├─ user: "Start task"
│ └─ assistant: "Plan"
│ ├─ • user: "Try approach A"
│ │ └─ • assistant: "A result"
│ │ └─ • [milestone] user: "Continue A"
│ └─ user: "Try approach B"
│ └─ assistant: "B result"
The selector recenters around current selection and shows up to:
max(5, floor(terminalHeight / 2))rows
Keybindings inside tree selector
Up/Down: move selection (wraps)Left/Right: page up / page downEnter: select nodeEsc: clear search if active; otherwise close selectorCtrl+C: close selectorType: append to search queryBackspace: delete search characterShift+L: edit/clear label on selected entryCtrl+O: cycle filter forwardShift+Ctrl+O: cycle filter backwardAlt+D/T/U/L/A: jump directly to specific filter mode
Filters and search semantics
Filter modes (TreeList):
defaultno-toolsuser-onlylabeled-onlyall
default
Shows conversational nodes plus any entry types not explicitly suppressed. It hides these setting/bookkeeping entry types:
labelcustommodel_changethinking_level_change
Other internal entry types that are not rendered specially may appear as blank rows in current code.
no-tools
Same as default, plus hides toolResult messages.
user-only
Only message entries where role is user.
labeled-only
Only entries that currently resolve to a label.
all
Everything in the session tree, including bookkeeping/custom entries.
Tool-only assistant node behavior
Assistant messages that contain only tool calls (no text) are hidden by default in all filtered views unless:
- message is error/aborted (
stopReasonnotstop/toolUse), or - it is the current leaf (always kept visible)
Search behavior
- Query is tokenized by spaces
- Matching is fuzzy (subsequence) and case-insensitive (
fuzzyMatch) - All tokens must match (AND semantics)
- Searchable text includes label, role, and type-specific content (message text, branch summary text, custom type, tool command snippets, etc.)
Selection outcomes (important)
navigateTree computes new leaf behavior from selected entry type:
Selecting user message
- New leaf becomes selected entry’s
parentId - If parent is
null(root user message), leaf resets to root (resetLeaf()) - Selected message text is copied to editor for editing/resubmit
Selecting custom_message
- Same leaf rule as user messages (
parentId) - Text content is extracted and copied to editor
Selecting non-user node (assistant/tool/summary/compaction/custom bookkeeping/etc.)
- New leaf becomes selected node id
- Editor is not prefilled
Selecting current leaf
- No-op; selector closes with “Already at this point”
Selection decision (simplified):
selected node
│
├─ is current leaf? ── yes ──> close selector (no-op)
│
├─ is user/custom_message? ── yes ──> leaf := parentId (or resetLeaf for root)
│ + prefill editor text
│
└─ otherwise ──> leaf := selected node id
+ no editor prefill
Summary-on-switch flow
Summary prompt is controlled by branchSummary.enabled (default: false).
When enabled, after picking a node the UI prompts:
No summarySummarizeSummarize with custom prompt
Flow details:
- Escape in summary prompt reopens tree selector
- Custom prompt cancellation returns to summary choice loop
- During summarization, UI shows loader and binds
EsctoabortBranchSummary() - If summarization aborts, tree selector reopens and no move is applied
navigateTree internals:
- Collects abandoned-branch entries from old leaf to common ancestor
- Emits
session_before_tree(extensions can cancel or inject summary) - Uses default summarizer only if requested and needed
- Applies move with:
branchWithSummary(...)when summary existsbranch(newLeafId)for non-root move without summaryresetLeaf()for root move without summary
- Replaces agent conversation with rebuilt session context
- Emits
session_tree
Note: if user requests summary but there is nothing to summarize, navigation proceeds without creating a summary entry.
Labels
Label edits in tree UI call appendLabelChange(targetId, label).
- non-empty label sets/updates resolved label
- empty label clears it
- labels are stored as append-only
labelentries - tree nodes display resolved label state, not raw label-entry history
/tree vs adjacent operations
| Operation | Scope | Result |
|---|---|---|
/tree | Current session file | Moves leaf to selected point (same file) |
/branch | Usually current session file -> new session file | By default branches from selected user message into a new session file; if doubleEscapeAction = "tree", /branch opens tree navigation UI instead |
/fork | Whole current session | Duplicates session into a new persisted session file |
/resume | Session list | Switches to another session file |
Key distinction: /tree is a navigation/repositioning tool inside one session file. /branch, /fork, and /resume all change session-file context.
Operator workflows
Re-run from an earlier user prompt without losing current branch
/tree- search/select earlier user message
- choose
No summary(or summarize if needed) - edit prefilled text in editor
- submit
Effect: new branch grows from selected point within same session file.
Leave current branch with context breadcrumb
- enable
branchSummary.enabled /treeand select target node- choose
Summarize(or custom prompt)
Effect: a branch_summary entry is appended at the target position before continuing.
Investigate hidden bookkeeping entries
/tree- press
Alt+A(all) - search for
model,thinking,custom, or labels
Effect: inspect full internal timeline, not just conversational nodes.
Bookmark pivot points for later jumps
/tree- move to entry
Shift+Land set label- later use
Alt+L(labeled-only) to jump quickly
Effect: fast navigation among durable branch landmarks.
RPC Protocol Reference
RPC mode runs the coding agent as a newline-delimited JSON protocol over stdio.
- stdin: commands (
RpcCommand), extension UI responses, and host-tool updates/results - stdout: a ready frame, command responses (
RpcResponse), session/agent events, extension UI requests, host-tool requests/cancellations
Primary implementation:
src/modes/rpc/rpc-mode.tssrc/modes/rpc/rpc-types.tssrc/session/agent-session.tspackages/agent/src/agent.tspackages/agent/src/agent-loop.ts
Startup
veyyon --mode rpc [regular CLI options]
Behavior notes:
@fileCLI arguments are rejected in RPC mode.- RPC mode disables automatic session title generation by default to avoid an extra model call.
- RPC mode host-defaults a small set of settings so embedders inherit Veyyon’s neutral defaults:
subagent.isolation.mode/merge/commits,subagent.delegation,subagent.batch,subagent.maxConcurrency,subagent.maxNestedSpawnDepth,subagent.agents,memory.backend, andmemories.enabled, plusasync.enabled,async.maxJobs,bash.autoBackground.enabled, andbash.autoBackground.thresholdMs. The default is only applied when the path is unset: any explicit configuration (caller overrides,--configoverlays, or the profileconfig.yml) is preserved.todo.*settings are always caller-controlled in protocol modes. - The process reads stdin as JSONL (
readJsonl(Bun.stdin.stream())). - At startup it writes
{ "type": "ready" }before processing commands. - When stdin closes, pending host-tool calls and host-URI requests are rejected and the process exits with code
0. - Responses/events are written as one JSON object per line.
Transport and Framing
Each frame is a single JSON object followed by \n.
There is no envelope beyond the object shape itself.
Outbound frame categories (stdout)
- Ready frame (
{ type: "ready" }) RpcResponse({ type: "response", ... })AgentSessionEventobjects (agent_start,message_update, etc.)RpcExtensionUIRequest({ type: "extension_ui_request", ... })- Host tool requests/cancellations (
host_tool_call,host_tool_cancel) - Host URI requests/cancellations (
host_uri_request,host_uri_cancel) - Extension errors (
{ type: "extension_error", extensionPath, event, error }) - Available-commands updates (
{ type: "available_commands_update", commands }), emitted at startup and whenever command metadata changes - Prompt lifecycle hints (
{ type: "prompt_result", id?, agentInvoked }) for scheduled prompts that later resolve without invoking the agent - Subagent frames (
subagent_lifecycle,subagent_progress,subagent_event), gated byset_subagent_subscription - Builtin slash-command side channels (
command_output,session_info_update,config_update)
Inbound frame categories (stdin)
RpcCommandRpcExtensionUIResponse({ type: "extension_ui_response", ... })- Host tool updates/results (
host_tool_update,host_tool_result) - Host URI results (
host_uri_result)
Request/Response Correlation
All commands accept optional id?: string.
- If provided, normal command responses echo the same
id. RpcClientrelies on this for pending-request resolution.
Important edge behavior from runtime:
- Unknown command responses are emitted with
id: undefined(even if the request had anid). - Parse/handler exceptions in the input loop emit
command: "parse"withid: undefined. promptandabort_and_promptreturn immediate success, then may emit a later error response with the same id if async prompt scheduling fails.promptsuccess responses may includedata.agentInvoked.falsemeans the prompt completed locally without an agent turn;truemeans the prompt produced agent lifecycle events; omitted means the host must rely on session events for completion.abort_and_promptdoes not currently emitdata.agentInvokedorprompt_result; hosts should treat it as the legacy abort-then-schedule path and rely on session events or same-id scheduling errors.
Command Schema (canonical)
RpcCommand is defined in src/modes/rpc/rpc-types.ts:
Prompting
{ id?, type: "prompt", message: string, images?: ImageContent[], streamingBehavior?: "steer" | "followUp" }{ id?, type: "steer", message: string, images?: ImageContent[] }{ id?, type: "follow_up", message: string, images?: ImageContent[] }{ id?, type: "abort" }{ id?, type: "abort_and_prompt", message: string, images?: ImageContent[] }{ id?, type: "new_session", parentSession?: string }
State
{ id?, type: "get_state" }{ id?, type: "get_available_commands" }{ id?, type: "set_todos", phases: TodoPhase[] }{ id?, type: "set_host_tools", tools: RpcHostToolDefinition[] }{ id?, type: "set_host_uri_schemes", schemes: RpcHostUriSchemeDefinition[] }{ id?, type: "set_subagent_subscription", level: "off" | "progress" | "events" }{ id?, type: "get_subagents" }{ id?, type: "get_subagent_messages", subagentId?: string, sessionFile?: string, fromByte?: number }
Model
{ id?, type: "set_model", provider: string, modelId: string }{ id?, type: "cycle_model" }{ id?, type: "get_available_models" }
Thinking
{ id?, type: "set_thinking_level", level: ThinkingLevel }{ id?, type: "cycle_thinking_level" }
Queue modes
{ id?, type: "set_steering_mode", mode: "all" | "one-at-a-time" }{ id?, type: "set_follow_up_mode", mode: "all" | "one-at-a-time" }{ id?, type: "set_interrupt_mode", mode: "immediate" | "wait" }
Compaction
{ id?, type: "compact", customInstructions?: string }{ id?, type: "set_auto_compaction", enabled: boolean }
Retry
{ id?, type: "set_auto_retry", enabled: boolean }{ id?, type: "abort_retry" }
Bash
{ id?, type: "bash", command: string }{ id?, type: "abort_bash" }
bash is dispatched concurrently: the RPC server continues reading commands
while the shell command runs, so abort_bash (or any other command) sent
during a long-running bash is handled without waiting for it to finish on
its own. The bash response is emitted when the command completes; hosts
correlate it via id. Ordering across concurrent commands is not guaranteed
, clients MUST match responses on id, not on emission order.
Session
{ id?, type: "get_session_stats" }{ id?, type: "export_html", outputPath?: string }{ id?, type: "switch_session", sessionPath: string }{ id?, type: "branch", entryId: string }{ id?, type: "get_branch_messages" }{ id?, type: "get_last_assistant_text" }{ id?, type: "set_session_name", name: string }{ id?, type: "handoff", customInstructions?: string }
Messages
{ id?, type: "get_messages" }
Login
{ id?, type: "get_login_providers" }{ id?, type: "login", providerId: string }
Response Schema
All command results use RpcResponse:
- Success:
{ id?, type: "response", command: <command>, success: true, data?: ... } - Failure:
{ id?, type: "response", command: string, success: false, error: string }
Data payloads are command-specific and defined in rpc-types.ts.
prompt payload
prompt is acknowledged after the command is accepted, not after a model turn finishes:
{
"id": "req_1",
"type": "response",
"command": "prompt",
"success": true,
"data": { "agentInvoked": false }
}
data.agentInvoked: false is a completion signal for local-only prompts, including slash commands that produce output without starting an agent turn. data.agentInvoked: true means the prompt produced agent lifecycle events; those events can be emitted before or after the prompt response depending on the command path. Older runtimes may omit data; hosts should then rely on agent_end, custom message completion, or prompt_result.
prompt_result is emitted when a prompt was accepted immediately but later resolves as local-only:
{ "type": "prompt_result", "id": "req_1", "agentInvoked": false }
Local-only slash commands may emit command_output frames before completing via data.agentInvoked: false or a later prompt_result. They do not emit agent_end.
get_state payload
{
"model": { "provider": "...", "id": "..." },
"thinkingLevel": "off|minimal|low|medium|high|xhigh|max",
"isStreaming": false,
"isCompacting": false,
"steeringMode": "all|one-at-a-time",
"followUpMode": "all|one-at-a-time",
"interruptMode": "immediate|wait",
"sessionFile": "...",
"sessionId": "...",
"sessionName": "...",
"autoCompactionEnabled": true,
"messageCount": 0,
"queuedMessageCount": 0,
"todoPhases": [
{
"id": "phase-1",
"name": "Todos",
"tasks": [
{
"id": "task-1",
"content": "Map the tool surface",
"status": "in_progress"
}
]
}
],
"systemPrompt": ["..."],
"dumpTools": [
{
"name": "read",
"description": "Read files and URLs",
"parameters": {}
}
],
"contextUsage": {
"tokens": 1100,
"contextWindow": 200000,
"percent": 0.55
}
}
set_todos payload
Replaces the in-memory todo state for the current session and returns the normalized phase list:
{
"id": "req_2",
"type": "set_todos",
"phases": [
{
"id": "phase-1",
"name": "Evaluation",
"tasks": [
{
"id": "task-1",
"content": "Map the read tool surface",
"status": "in_progress"
},
{
"id": "task-2",
"content": "Exercise edit operations",
"status": "pending"
}
]
}
]
}
This is useful for hosts that want to pre-seed a plan before the first prompt.
set_host_tools payload
Replaces the current set of host-owned tools that the RPC server may call back into over stdio:
{
"id": "req_3",
"type": "set_host_tools",
"tools": [
{
"name": "echo_host",
"label": "Echo Host",
"description": "Echo a value from the embedding host",
"parameters": {
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"],
"additionalProperties": false
}
}
]
}
The response payload is:
{
"toolNames": ["echo_host"]
}
These tools are added to the active session tool registry before the next model
call. Re-sending set_host_tools replaces the previous host-owned set.
set_host_uri_schemes payload
Replaces the current set of host-owned URL schemes the RPC server should dispatch reads/writes through:
{
"id": "req_4",
"type": "set_host_uri_schemes",
"schemes": [
{
"scheme": "db",
"description": "Virtual db row files",
"writable": true,
"immutable": false
}
]
}
The response payload is:
{
"schemes": ["db"]
}
Schemes are case-insensitive on the wire and normalized to lowercase before
the response is sent. Re-sending set_host_uri_schemes replaces the entire
previous set, schemes missing from the new list are unregistered.
Event Stream Schema
RPC mode forwards AgentSessionEvent objects from AgentSession.subscribe(...).
Common event types:
agent_start,agent_endturn_start,turn_endmessage_start,message_update,message_endtool_execution_start,tool_execution_update,tool_execution_endauto_compaction_start,auto_compaction_endauto_retry_start,auto_retry_endttsr_triggeredtodo_remindertodo_auto_clear
Extension runner errors are emitted separately as:
{
"type": "extension_error",
"extensionPath": "...",
"event": "...",
"error": "..."
}
message_update includes streaming deltas in assistantMessageEvent (text/thinking/toolcall deltas).
Prompt/Queue Concurrency and Ordering
This is the most important operational behavior.
Immediate ack vs completion
prompt and abort_and_prompt are acknowledged immediately:
{ "id": "req_1", "type": "response", "command": "prompt", "success": true }
That means:
- command acceptance != run completion
- agent turns complete via
agent_end - local-only prompts complete via
data.agentInvoked: falseon the response or via a laterprompt_result
While streaming
AgentSession.prompt() requires streamingBehavior during active streaming:
"steer"=> queued steering message (interrupt path)"followUp"=> queued follow-up message (post-turn path)
If omitted during streaming, prompt fails.
Queue defaults
From packages/agent/src/agent.ts defaults:
steeringMode:"one-at-a-time"followUpMode:"one-at-a-time"interruptMode:"immediate"
Mode semantics
set_steering_mode/set_follow_up_mode"one-at-a-time": dequeue one queued message per turn"all": dequeue entire queue at once
set_interrupt_mode"immediate": tool execution checks steering between tool calls; pending steering can abort remaining tool calls in the turn"wait": defer steering until turn completion
Extension UI Sub-Protocol
Extensions in RPC mode use request/response UI frames.
Outbound request
RpcExtensionUIRequest (type: "extension_ui_request") methods:
select,confirm,input,editor,cancelnotify,setStatus,setWidget,setTitle,set_editor_textopen_url(emitted by RPC login flows)
Runtime note:
- Automatic session title generation is disabled in RPC mode, and
setTitleUI requests are also suppressed by default because most hosts do not have a meaningful terminal-title surface. SetVEYYON_RPC_EMIT_TITLE=1to opt back in to the UI event only.
Example:
{
"type": "extension_ui_request",
"id": "123",
"method": "confirm",
"title": "Confirm",
"message": "Continue?",
"timeout": 30000
}
Inbound response
RpcExtensionUIResponse (type: "extension_ui_response"):
{ type: "extension_ui_response", id: string, value: string }{ type: "extension_ui_response", id: string, confirmed: boolean }{ type: "extension_ui_response", id: string, cancelled: true, timedOut?: boolean }
If a dialog has a timeout, RPC mode resolves to a default value when timeout/abort fires.
Host Tool Sub-Protocol
RPC hosts can expose custom tools to the agent by sending set_host_tools, then
serving execution requests over the same transport.
Outbound request
When the agent wants the host to execute one of those tools, RPC mode emits:
{
"type": "host_tool_call",
"id": "host_1",
"toolCallId": "toolu_123",
"toolName": "echo_host",
"arguments": { "message": "hello" }
}
If the tool execution is later aborted, RPC mode emits:
{
"type": "host_tool_cancel",
"id": "host_cancel_1",
"targetId": "host_1"
}
Inbound updates and completion
Hosts can optionally stream progress:
{
"type": "host_tool_update",
"id": "host_1",
"partialResult": {
"content": [{ "type": "text", "text": "working" }]
}
}
Completion uses:
{
"type": "host_tool_result",
"id": "host_1",
"result": {
"content": [{ "type": "text", "text": "done" }]
}
}
Set top-level isError: true on host_tool_result to reject the pending host tool call and surface the returned text content as a tool error.
Host URI Sub-Protocol
RPC hosts can also own custom URL schemes (virtual files). After
set_host_uri_schemes, every read of <scheme>://… and write of
<scheme>://… (when registered as writable) is bounced back to the host
over the same transport.
Outbound request
When a session tool resolves a host-owned URL, RPC mode emits:
{
"type": "host_uri_request",
"id": "uri_1",
"operation": "read",
"url": "db://users/42"
}
Writes look the same with "operation": "write" and an additional
"content": "..." field containing the full replacement bytes.
If the request is later aborted (caller cancels, session ends), RPC mode emits:
{
"type": "host_uri_cancel",
"id": "uri_cancel_1",
"targetId": "uri_1"
}
Inbound result
For successful reads:
{
"type": "host_uri_result",
"id": "uri_1",
"content": "id=42\nname=Alice\n",
"contentType": "text/plain",
"notes": ["fresh from cache"],
"immutable": false
}
For successful writes, omit content:
{ "type": "host_uri_result", "id": "uri_1" }
To reject the request, set isError: true and either populate error with
a message or fall back to content for textual error surfacing:
{
"type": "host_uri_result",
"id": "uri_1",
"isError": true,
"error": "row 42 not found"
}
Constraints
- The agent’s
edittool does not target host URIs. Hosts that want to mutate virtual files exposewriteand let the model use thewritetool with replacement content. - Schemes are global to the process;
set_host_uri_schemesreplaces the previous set, unregistering anything not in the new list. - Schemes are normalized to lowercase before registration.
Error Model and Recoverability
Command-level failures
Failures are success: false with string error.
{
"id": "req_2",
"type": "response",
"command": "set_model",
"success": false,
"error": "Model not found: provider/model"
}
Recoverability expectations
- Most command failures are recoverable; process remains alive.
- Malformed JSONL / parse-loop exceptions emit a
parseerror response and continue reading subsequent lines. - Empty
set_session_nameis rejected (Session name cannot be empty). - Extension UI responses with unknown
idare ignored. - Process termination conditions are stdin close or explicit extension-triggered shutdown after the current command.
Compact Command Flows
1) Prompt and stream
stdin:
{ "id": "req_1", "type": "prompt", "message": "Summarize this repo" }
stdout sequence (typical):
{ "id": "req_1", "type": "response", "command": "prompt", "success": true }
{ "type": "agent_start" }
{ "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "delta": "..." }, "message": { "role": "assistant", "content": [] } }
{ "type": "agent_end", "messages": [] }
2) Prompt during streaming with explicit queue policy
stdin:
{
"id": "req_2",
"type": "prompt",
"message": "Also include risks",
"streamingBehavior": "followUp"
}
3) Inspect and tune queue behavior
stdin:
{ "id": "q1", "type": "get_state" }
{ "id": "q2", "type": "set_steering_mode", "mode": "all" }
{ "id": "q3", "type": "set_interrupt_mode", "mode": "wait" }
4) Extension UI round trip
stdout:
{
"type": "extension_ui_request",
"id": "ui_7",
"method": "input",
"title": "Branch name",
"placeholder": "feature/..."
}
stdin:
{ "type": "extension_ui_response", "id": "ui_7", "value": "feature/rpc-host" }
Notes on RpcClient helper
src/modes/rpc/rpc-client.ts is a convenience wrapper, not the protocol definition.
Current helper characteristics:
- Spawns
bun <cliPath> --mode rpc - Correlates responses by generated
req_<n>ids - Dispatches recognized core
AgentEventtypes to listeners - Supports host-owned custom tools via
setCustomTools()and automatic handling ofhost_tool_call/host_tool_cancel - Wraps common protocol commands including OAuth
getLoginProviders()/login(...); use raw protocol frames for any surface not wrapped by the helper.
Use raw protocol frames if you need complete surface coverage.
SDK
The SDK is the in-process integration surface for @veyyon/coding-agent.
Use it when you want direct access to agent state, event streaming, tool wiring, and session control from your own Bun/Node process.
If you need cross-language/process isolation, use RPC mode instead.
Installation
Veyyon ships through GitHub only. Its packages are not on npm or any other
registry, and they cannot be: they depend on each other with Bun’s
workspace:* and catalog: protocols, which resolve only inside a checkout.
bun add @veyyon/coding-agent fails, and so does every other registry install.
You consume the SDK from a checkout, linked into your project.
Clone the repository and install its dependencies:
git clone https://github.com/santhreal/veyyon.git
cd veyyon
bun install
If you already have a Veyyon checkout, use it instead of cloning again. The installer does not create one, so this is a clone you made yourself.
Register the package with Bun, from the checkout:
bun --cwd=packages/coding-agent link
Then link it into your own project:
cd /path/to/your-project
bun link @veyyon/coding-agent
Your project now resolves @veyyon/coding-agent to the checkout. To move to a
newer version, update the checkout (git pull && bun install); the link keeps
pointing at it.
Entry points
@veyyon/coding-agent exports the SDK APIs from the package root (and also via @veyyon/coding-agent/sdk).
Core exports for embedders:
createAgentSessionSessionManagerSettingsAuthStorageModelRegistrydiscoverAuthStorage- Discovery helpers (
discoverExtensions,discoverSkills,discoverContextFiles,discoverPromptTemplates,discoverSlashCommands,discoverCustomTSCommands,discoverMCPServers) - Tool factory surface (
createTools,BUILTIN_TOOLS, tool classes)
Quick start (auto-discovery defaults)
import { createAgentSession } from "@veyyon/coding-agent";
const { session, modelFallbackMessage } = await createAgentSession();
if (modelFallbackMessage) {
process.stderr.write(`${modelFallbackMessage}\n`);
}
const unsubscribe = session.subscribe((event) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("Summarize this repository in 3 bullets.");
unsubscribe();
await session.dispose();
What createAgentSession() discovers by default
createAgentSession() follows “provide to override, omit to discover”.
If omitted, it resolves:
cwd:getProjectDir()agentDir: active profile agent dir viagetAgentDir()(default~/.veyyon/profiles/default/agent; named profile~/.veyyon/profiles/<name>/agent)globalConfigRoot: cross-profile vault/key root viagetGlobalConfigRootDir()(normally~/.veyyon)authStorage:discoverAuthStorage(agentDir)modelRegistry:new ModelRegistry(authStorage)+ backgroundrefreshInBackground()when the registry is not providedsettings:await Settings.init({ cwd, agentDir })sessionManager:SessionManager.create(cwd)(file-backed)- skills/context files/prompt templates/slash commands/extensions/custom TS commands
- built-in tools via
createTools(...) - MCP tools (enabled by default; Exa MCP servers are folded into native Exa integration, and browser automation MCP servers are filtered when the built-in browser tool is enabled)
- LSP integration (enabled by default)
eventBus: newEventBus()unless supplied
Required vs optional inputs
Typically you must provide only what you want to control:
- Must provide: nothing for a minimal session
- Usually provide explicitly in embedders:
sessionManager(if you need in-memory or custom location)authStorage+modelRegistry(if you own credential/model lifecycle)modelormodelPattern(if deterministic model selection matters)settings(if you need isolated/test config)
For a multi-tenant, test, or otherwise isolated SDK host, set globalConfigRoot to a private
directory so the session cannot read or write the host user’s cross-profile secret vault or vault
key. The override affects vault/key resolution only; agentDir continues to control profile-local
configuration. When omitted, globalConfigRoot defaults to getGlobalConfigRootDir() exactly as it
does for the CLI.
import { chmod, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createAgentSession } from "@veyyon/coding-agent";
const privateVaultRoot = await mkdtemp(path.join(tmpdir(), "veyyon-sdk-"));
await chmod(privateVaultRoot, 0o700);
const { session } = await createAgentSession({
globalConfigRoot: privateVaultRoot,
// Other isolated-host options...
});
Session manager behavior (persistent vs in-memory)
AgentSession always uses a SessionManager; behavior depends on which factory you use.
File-backed (default)
import { createAgentSession, SessionManager } from "@veyyon/coding-agent";
const { session } = await createAgentSession({
sessionManager: SessionManager.create(process.cwd()),
});
console.log(session.sessionFile); // absolute .jsonl path
- Persists conversation/messages/state deltas to session files.
- Supports resume/open/list/fork workflows.
sessionFileis defined on the session options.
In-memory
import { createAgentSession, SessionManager } from "@veyyon/coding-agent";
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
console.log(session.sessionFile); // undefined
- No filesystem persistence.
- Useful for tests, ephemeral workers, request-scoped agents.
- Session methods still work, but persistence-specific behaviors (file resume/fork paths) are naturally limited.
Resume/open/list helpers
import { SessionManager } from "@veyyon/coding-agent";
const recent = await SessionManager.continueRecent(process.cwd());
const listed = await SessionManager.list(process.cwd());
const opened = listed[0] ? await SessionManager.open(listed[0].path) : null;
Model and auth wiring
createAgentSession() uses ModelRegistry + AuthStorage for model selection and API key resolution.
Explicit wiring
import {
createAgentSession,
discoverAuthStorage,
ModelRegistry,
SessionManager,
} from "@veyyon/coding-agent";
const authStorage = await discoverAuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
await modelRegistry.refresh();
const available = modelRegistry.getAvailable();
if (available.length === 0)
throw new Error("No authenticated models available");
const { session } = await createAgentSession({
authStorage,
modelRegistry,
model: available[0],
thinkingLevel: "medium",
sessionManager: SessionManager.inMemory(),
});
Selection order when model is omitted
When no explicit model/modelPattern is provided:
- restore model from existing session (if restorable + key available)
- settings default model role (
default) - first available model with valid auth
If restore fails, modelFallbackMessage explains fallback.
Auth priority
AuthStorage.getApiKey(...) resolves in this order:
- runtime override (
setRuntimeApiKey, used by CLI--api-key) - config-sourced API key override (
models.ymlproviderapiKey) - stored OAuth credential, including refresh when needed
- stored login-sourced API-key credential
- provider environment variables
- stored non-login API-key credential (may be a stale broker-migrated copy)
- custom-provider resolver fallback
Event subscription model
Subscribe with session.subscribe(listener); it returns an unsubscribe function.
const unsubscribe = session.subscribe((event) => {
switch (event.type) {
case "agent_start":
case "turn_start":
case "tool_execution_start":
break;
case "message_update":
if (event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
break;
}
});
AgentSessionEvent includes core AgentEvent plus session-level events:
auto_compaction_start/auto_compaction_endauto_retry_start/auto_retry_endretry_fallback_applied/retry_fallback_succeededttsr_triggeredtodo_reminder/todo_auto_clearirc_message
Prompt lifecycle
session.prompt(text, options?) is the primary entry point.
Behavior:
- optional command/template expansion (
/commands, custom commands, file slash commands, prompt templates) - if currently streaming:
streamingBehavior: "steer" | "followUp"chooses howprompt()queues- extension
sendUserMessage(content)defaults to steer whendeliverAsis omitted - queued messages are preserved instead of throwing work away
- if idle:
- validates model + API key
- appends user message
- starts agent turn
Related APIs:
sendUserMessage(content, { deliverAs? })steer(text, images?)followUp(text, images?)sendCustomMessage({ customType, content, ... }, { deliverAs?, triggerTurn? })abort()
Tools and extension integration
Built-ins and filtering
- Built-ins come from
createTools(...)andBUILTIN_TOOLS. toolNamesacts as an allowlist for built-ins.customToolsand extension-registered tools are still included.- Hidden tools (for example
yield) are opt-in unless required by options.
const { session } = await createAgentSession({
toolNames: ["read", "search", "write"],
requireYieldTool: true,
});
Tool names have one owner
Every tool name is declared once, in packages/coding-agent/src/tools/builtin-names.ts:
BUILTIN_TOOL_NAMES for the tools offered by default, HIDDEN_TOOL_NAMES for the ones a caller or
a mode turns on, and TOOL, a map derived from both.
Use TOOL inside the package rather than writing the name again:
import { TOOL } from "./tools/builtin-names";
if (!requestedTools.includes(TOOL.yield)) requestedTools.push(TOOL.yield);
TOOL.yield has the literal type "yield", so it fits anywhere the string did. The reason to
prefer it is what happens when a tool is renamed: the key disappears and every site that used it
stops compiling. A hand-written "yield" keeps compiling and quietly stops matching, and the only
symptom is a tool that is no longer there.
A few strings in the package share a spelling with a tool while naming something else, such as the
"task" agent id, the "write" approval tier, and the subagent.output: "yield" setting value.
Those stay literals and carry a // not-a-tool-name: comment saying which they are. The test
test/tools/tool-name-literals-have-one-owner.test.ts reads the selection sites and fails on any
unmarked tool-name literal.
As a caller of the SDK you keep passing plain strings: toolNames takes the names as text. Retired workspace-search names (glob, grep, find, and ast_grep) normalize to the canonical search builtin at this compatibility boundary; they are not registered as model-facing tools.
Extensions
extensions: inlineExtensionFactory[]additionalExtensionPaths: load extra extension filesdisableExtensionDiscovery: disable automatic extension scanningpreloadedExtensions: reuse already loaded extension set
Runtime tool set changes
AgentSession supports runtime activation updates:
getActiveToolNames()getAllToolNames()setActiveToolsByName(names)refreshMCPTools(mcpTools)
System prompt is rebuilt to reflect active tool changes.
Discovery helpers
Use these when you want partial control without recreating internal discovery logic:
discoverAuthStorage(agentDir?)discoverExtensions(cwd?)discoverSkills(cwd?, _agentDir?, settings?)discoverContextFiles(cwd?, _agentDir?)discoverPromptTemplates(cwd?, agentDir?)discoverSlashCommands(cwd?)discoverCustomTSCommands(cwd?, agentDir?)discoverMCPServers(cwd?)buildSystemPrompt(options?)
Subagent-oriented options
For SDK consumers building orchestrators (similar to task executor flow):
outputSchema: passes structured output expectation into tool contextrequireYieldTool: forcesyieldtool inclusiontaskDepth: recursion-depth context for nested task sessionsparentTaskPrefix: artifact naming prefix for nested task outputs
These are optional for normal single-agent embedding.
createAgentSession() return value
type CreateAgentSessionResult = {
session: AgentSession;
extensionsResult: LoadExtensionsResult;
setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
mcpManager?: MCPManager;
modelFallbackMessage?: string;
lspServers?: Array<{
name: string;
status: "connecting" | "ready" | "error" | "available";
fileTypes: string[];
error?: string;
}>;
eventBus: EventBus;
};
Use setToolUIContext(...) only if your embedder provides UI capabilities that tools/extensions should call into.
Startup performance
createAgentSession() runs two background optimizations to overlap I/O with the rest of session setup:
-
Model-host preconnect. As soon as the model is resolved, the SDK fires a best-effort
fetch.preconnect()call against the model host so DNS + TCP + TLS + HTTP/2 to the provider’s host happens in parallel with extension/skill load, tool registry build, and system-prompt assembly. The first realfetch(...)then reuses the warm connection, saving 100–300 ms on transcontinental hops (e.g. residential IP →api.anthropic.com). Implementation lives inpreconnectModelHost()inpackages/coding-agent/src/sdk.ts. If Bun’spreconnectis unavailable (non-Bun runtime) or the call throws, the optimization is silently skipped: never a hard dependency. Applies to every mode (interactive, print, RPC, ACP). -
Conditional LSP warmup. Startup LSP servers (those returned by
discoverStartupLspServers(cwd)) are only warmed when all of these hold:enableLsp !== falseon the session options, andoptions.hasUI === true(interactive TUI), and- the
lsp.lazysetting is disabled (it defaults totrue).
With
lsp.lazyenabled, the default, no language servers are launched at startup at all; each server cold-starts on first use, i.e. when the agent invokes thelsptool or an edit/write touches a file whose extension matches the server’sfileTypes. Print / script / RPC / ACP invocations (hasUI=false) skip the warmup regardless of the setting: they don’t render the warmup status indicator and typically finish before the language servers would stabilize, so warming them just spends CPU parsing biginitializeresponses concurrently with the LLM stream consumer and jitters perceived latency. Tools that actually need an LSP server still spin one up on demand throughgetOrCreateClient(), only the startup warmup is skipped. The returnedlspServersfield inCreateAgentSessionResultis still populated for UI sessions in lazy mode, recognized servers are discovered (no processes spawned) and reported with status"available"so the welcome screen and/statuscan list them; it isundefinedonly whenenableLsp === falseorhasUI === false.
Minimal controlled embed example
import {
createAgentSession,
discoverAuthStorage,
ModelRegistry,
SessionManager,
Settings,
} from "@veyyon/coding-agent";
const authStorage = await discoverAuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
await modelRegistry.refresh();
const settings = Settings.isolated({
"compaction.enabled": true,
"retry.enabled": true,
});
const { session } = await createAgentSession({
authStorage,
modelRegistry,
settings,
sessionManager: SessionManager.inMemory(),
toolNames: ["read", "search", "edit", "write"],
enableMCP: false,
enableLsp: true,
});
session.subscribe((event) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("Find all TODO comments in this repo and propose fixes.");
await session.dispose();
Call session.dispose() once when the host no longer needs the session. Repeated or concurrent calls share the first disposal transaction. The first call’s shutdown options are authoritative, and cleanup still detaches SDK listeners if audit flushing reports an error.
Exit codes
Exit codes follow common shell conventions for scripts and CI.
| Code | Meaning |
|---|---|
0 | Success. |
1 | A Veyyon runtime error: bad config, auth failure, no such session, an unrecoverable runtime error, or the fallback when a child process ended without a reportable status. |
2 | A command-line usage error, following the conventional shell meaning of code 2. You get it for an unrecognized flag, a bad flag value, a missing required argument, a mistyped subcommand, or a single-shot run (--print) with no prompt to send. It is the same code whether the mistake is in a root flag (veyyon --nope) or in a subcommand’s arguments (veyyon config --nope, veyyon completions tcsh, veyyon config get). Veyyon fails before starting a session, so no LLM call or MCP connection happens. |
130 | Veyyon hard-aborted on a Ctrl+C that arrived while it was already shutting down, at 128 + SIGINT (128 + 2), rather than waiting on a teardown step that is stuck. An ordinary exit through the normal shutdown, including the double Ctrl+C or Ctrl+D that starts it, completes and returns 0. |
N | When Veyyon runs a child process (for example a shell tool command), the child’s own exit code passes through unchanged. |
128 + signal | On Unix, a child killed by a signal is reported as 128 + signal (the POSIX shell convention): SIGKILL (9) → 137, SIGTERM (15) → 143. |
Two guarantees hold everywhere:
- A failure is never reported as
0. An unknown or missing child status falls back to1, never success. - A signal death is surfaced as a distinct non-zero code, never swallowed.
The most useful distinction for a script is the one between 1 and 2. A 1 means the invocation
was valid and the attempt failed, so a retry may succeed. A 2 means the command line itself was
wrong, so an identical retry cannot succeed. Check for 2 before you loop:
veyyon --print "$prompt"
status=$?
if [ "$status" -eq 2 ]; then
echo "fix the command line before retrying" >&2
exit 2
fi
0, 1, 2 and 130 come from packages/coding-agent/src/cli/exit-codes.ts, and a test asserts
that this table and that module agree. The command framework in packages/utils/src/cli.ts declares
the same 2 as CLI_EXIT_USAGE, because it rejects a subcommand’s arguments before that module is
on the startup path; a test asserts the two numbers are equal. If you add a code, add it to the
table and to exit-codes.ts.
For the machine-readable event stream (including per-turn and per-tool outcomes), use the
Agent Client Protocol mode (veyyon acp); see the CLI reference.
File locations
Everything Veyyon stores lives under the config home, ~/.veyyon by default on every platform.
Override the directory name with VEYYON_CONFIG_DIR;
on Linux/macOS the XDG layout is available after veyyon config init-xdg.
The config home (~/.veyyon/)
The root itself holds only global, cross-profile state. Everything else is per-profile:
| Path | Contents |
|---|---|
config.yml | Global settings that apply across profiles: defaultProfile (which profile a bare veyyon launches), profileSharing, and the auth-broker keys authBrokerUrl / authBrokerToken. Not to be confused with a profile’s own config.yml (below). |
shared-auth/ | Shared credential store, used when profileSharing is on: agent.db (SQLite OAuth/API-key storage shared across profiles). |
AGENTS.md | Global instructions loaded into every profile’s session. Veyyon creates it on first run with a stripped-before-load guidance header. Keep profile-specific rules in the profile’s own AGENTS.md (below). See Instruction layers. |
install-id | Persistent per-install UUID. Shared by every profile. |
profiles/ | One directory per profile, including profiles/default/, see below. |
Profiles (~/.veyyon/profiles/<name>/)
Every profile, including default, is a directory under profiles/ with the same shape.
A profile has two layers:
Profile root (profiles/<name>/), operational state:
| Path | Contents |
|---|---|
logs/ | Log files (veyyon.YYYY-MM-DD.log). |
plugins/ | Installed plugins (node_modules/, manifest, lockfile). |
wt/ | Agent-managed git worktrees (PR checkouts, task isolation). |
cache/ | Caches: GitHub view cache, fastembed models, auth-broker snapshot. |
natives/, puppeteer/, python-env/ | Downloaded native binaries, Puppeteer browser cache, managed Python venv. |
stats.db, autoqa.db, gpu_cache.json | Usage stats, auto-QA state, GPU probe cache. |
reports/, remote/, remote-host/, ssh-control/, autoresearch/ | Reports, remote mounts, SSH control sockets, autoresearch state. |
Agent dir (profiles/<name>/agent/), identity and conversation state:
| Path | Contents |
|---|---|
config.yml | This profile’s settings (config.yaml also accepted). See Configuration. |
agent.db | Settings + auth storage (SQLite). |
sessions/ | Saved session transcripts, one per thread. |
blobs/ | Content-addressed attachment/blob store. |
history.db, models.db | Composer history, model cache. |
skills/, commands/, prompts/, tools/, themes/, modules/ | Skills, slash commands, prompt templates, custom tools, themes, Python modules. |
mcp.json, ssh.json | MCP server and SSH target config. |
keybindings.yml | This profile’s keybindings (keybindings.yaml accepted; legacy keybindings.json migrates on load). |
AGENTS.md | Profile-specific context appended to the assembled prompt. |
RULES.md | Sticky profile rules reattached near each turn. |
TITLE_SYSTEM.md | Optional system prompt for automatic session-title calls. |
PROMPT_SECTIONS/ | Persistent replacements or additions for named assembled-prompt sections. |
memories/, terminal-sessions/ | Memory store, terminal session state. |
cache/ | Agent-scoped caches (tiny title models, document conversions). |
Overriding the agent dir directly (VEYYON_CODING_AGENT_DIR) applies to the default profile only;
a named profile always derives its own agent dir.
Which profile launches
Resolution order for every veyyon / vey invocation:
--profile <name>on the command line.VEYYON_PROFILE. An explicitly emptyVEYYON_PROFILE=forces thedefaultprofile, bypassing step 3.defaultProfilein the global~/.veyyon/config.yml: set it withveyyon profile default <name>.- The
defaultprofile.
The name default always addresses profiles/default/ and cannot be removed.
Legacy layout migration
Before this layout, the default profile lived bare in the config root (~/.veyyon/agent/,
~/.veyyon/logs/, …). On first launch Veyyon migrates that state into profiles/default/
once, and will not guess if both layouts are present, the error states the exact
directories to reconcile.
Credential storage
Auth tokens live in the profile’s agent.db (or the OS keyring, depending on the configured
credential store). BYOK provider keys never land in plaintext config.yml; see
Signing in.
Project-local files
Alongside your project (not under the config home):
| Path | Purpose |
|---|---|
AGENTS.md / CLAUDE.md | Project instructions Veyyon auto-loads. These are the only configuration-shaped files a repository contributes. See AGENTS.md. |
.veyyon/ | Project-scoped data that follows the working directory: prompt templates (prompts/), personalities (personalities/), the project secret vault, and project-scope plugin installs. Settings, MCP servers, rules, hooks, tools, commands, skills, and agents are never read from it, because a checked-in file must not configure the agent. |
Architecture overview
Veyyon is a Bun/TypeScript coding agent (fork of oh-my-pi) with Rust hot paths (native grep, PTY, tree-sitter/AST via crates/veyyon-natives). The shipped CLI binary is veyyon. There is no separate app-server daemon in
the product surface.
The request path
prompt ──► veyyon
│
▼
AgentSession turn loop
│
▼
model stream + tool calls
│
▼
tool handlers (read, bash, edit, …) ──► results back to model
Interactive mode runs in the TUI. Non-interactive work uses veyyon with a prompt or subcommands such
as commit, grep, and models.
Subsystem map
| Area | Responsibility | Handbook |
|---|---|---|
| Sessions | JSONL trees, resume, fork, compact | Sessions |
| Edit | Hashline patches (default) | Edit engine |
| Approvals | Approval-mode gating on tool tiers | Approvals |
| Config | Layered config.yml, profiles | Config |
| MCP | External tool servers | MCP |
| Providers | Model registry + auth | Providers |
| Memory | off / local / mnemopi / hindsight | Memory |
Not part of the product surface: a standalone exec-server process or a separate backends.toml catalog. The table above is the subsystem map.
Approvals
Approvals decide when a tool or shell command runs on its own and when Veyyon pauses for the user. There is no OS-level command sandbox: Veyyon does not confine commands with Landlock, seccomp, Seatbelt, or bubblewrap. The boundary is policy the agent loop enforces before dispatch.
Responsibility
- Map the approval mode (
tools.approvalMode) to a per-tier decision (read / write / exec) forbash,edit,write, and related tools. - Apply per-tool overrides (
tools.approval→allow/deny/prompt) on top of the mode. - Apply the two argument-level boundaries the tier cannot see: a filesystem target outside the session working directory, and a call whose arguments carry a stored credential. Both prompt on every rung except
yolo, the shippedautoincluded. - Force a prompt for hard-coded flagged bash patterns. The destructive ones (recursive deletion of the home directory or a system directory, fork bombs, disk destruction, writes to the system account files) prompt on every rung,
yoloincluded: that is a floor rather than an ordinary prompt, and only an explicittools.approval.bash: allowlifts it, whiledenyremains a hard block. The merely dangerous ones (curl | sh,reboot,nc -e) prompt on every rung belowyolo. - Surface the approval prompt in the TUI before a gated command or edit runs.
Public boundary
tools.approvalMode in config.yml and the launch flags (--approval-mode, --auto-approve /
--yolo, --plan-yolo) resolve to a decision applied to the bash, edit, and write tools, with
plan-mode guards on top. Commands run in-process after policy resolution, there is no standalone
exec-server process in the shipped product.
Key concepts
| Concept | Meaning |
|---|---|
| Approval mode | Which tool tiers run without asking (plan, ask, ask-command, auto (default), yolo; legacy always-ask → ask, write and auto-edit → ask-command). |
| Per-tool policy | tools.approval overrides the mode for a named tool. |
| Flagged bash patterns | Hard-coded command shapes recorded as destructive or dangerous. The destructive ones prompt on every rung including yolo, and below yolo a per-tool allow does not lift them. The dangerous ones prompt on every rung below yolo. |
| Plan mode | Restricts mutating tools until the plan is approved (/plan). |
User-facing guide: Approvals.
Session and turn
Sessions are JSONL conversation trees; each turn is one user prompt through model streaming, tool calls, and the final assistant message.
Responsibility
- Persist append-only session entries with
id/parentIdlinkage - Track the active leaf for branching (
/tree,/branch,/fork) - Drive compaction when context limits approach (
/compact, auto-compact settings) - Coordinate tool execution, approvals, and subagent spawns per turn
Public boundary
- The
AgentSessionruns the turn loop. - On-disk layout:
~/.veyyon/profiles/default/agent/sessions/<dir-encoded>/<timestamp>_<id>.jsonl - Blob store:
~/.veyyon/profiles/default/agent/blobs/<sha256>
Sessions run in-process; there is no separate session daemon.
User guide: Sessions.
Config
Configuration controls models, approvals, memory, MCP, extensions, and TUI behavior. Veyyon loads
layered YAML/JSON from the user agent directories. A working tree never supplies configuration:
a checked-in .veyyon/config.yml is not read, because a repository is content you may not have
written.
Operator guide: Configuration. Every setting, by name: Settings, Settings reference.
Responsibility
- Resolve config roots (the active profile’s agent dir, plus Claude/Codex/Gemini compatibility paths at user level)
- Merge profile settings with
--configoverlays and runtime overrides; apply profiles (veyyon --profile <name>) - Validate against
settings-schema.ts; support--config <file>YAML overlay files (repeatable) - Feed resolved settings to sessions, tools, and discovery (skills, hooks, MCP, extensions)
Public boundary
- Primary user file:
~/.veyyon/profiles/default/agent/config.yml(or profile path under~/.veyyon/profiles/) - CLI:
veyyon config list|get|set,/settings,/reload-plugins(re-read without restart)
How configuration resolves
Roots scanned, precedence, and consumption by settings, skills, hooks, tools, and extensions.
Scope
Primary implementation:
packages/coding-agent/src/config.tspackages/coding-agent/src/config/config-file.ts(re-exported fromconfig.ts)packages/coding-agent/src/config/settings.tspackages/coding-agent/src/config/settings-schema.tspackages/coding-agent/src/discovery/builtin.tspackages/coding-agent/src/discovery/helpers.ts
Key integration points:
packages/coding-agent/src/capability/index.tspackages/coding-agent/src/discovery/index.tspackages/coding-agent/src/extensibility/skills.tspackages/coding-agent/src/extensibility/hooks/loader.tspackages/coding-agent/src/extensibility/custom-tools/loader.tspackages/coding-agent/src/extensibility/extensions/loader.ts
Resolution flow (visual)
Generic helper order (`config.ts`)
┌───────────────────────────────────────┐
│ 1) ~/.veyyon/profiles/default/agent, ~/.claude, ... │
│ 2) <cwd>/.veyyon, <cwd>/.claude, ... │
└───────────────────────────────────────┘
│
▼
capability providers enumerate items
(capability discovery reads HOME only: a working tree is
untrusted input and contributes nothing but context files;
the project bases above survive only in the generic helper
for the callers that still use it, such as TITLE_SYSTEM.md)
│
▼
provider priority sort + capability dedup
│
▼
subsystem-specific consumption
(settings, skills, hooks, tools, extensions)
1) Config roots and source order
Canonical roots
src/config.ts defines a fixed source priority list:
.veyyon(native).claude.codex.gemini
User-level bases:
~/.veyyon/profiles/default/agent~/.claude~/.codex~/.gemini
Project-level bases:
<cwd>/.veyyon<cwd>/.claude<cwd>/.codex<cwd>/.gemini
The project bases exist in the generic helper, but capability discovery no longer uses them: a checked-out working tree is untrusted input, so a repository contributes context files (AGENTS.md / CLAUDE.md) and nothing else. The remaining caller of the project bases is TITLE_SYSTEM.md discovery (see Session title prompt override).
CONFIG_DIR_NAME is .veyyon (packages/utils/src/dirs.ts).
Profiles
A named profile (veyyon --profile <name>, /profile <name> in the TUI, or VEYYON_PROFILE) selects which profile agent dir is active. The default profile is ~/.veyyon/profiles/default/agent/; profile <name> is ~/.veyyon/profiles/<name>/agent/. Paths written in this document as ~/.veyyon/profiles/default/agent/... mean the active profile’s agent directory.
The relocation is uniform across the native provider (builtin.ts) and the generic config.ts helpers. It covers slash commands, sticky rules, prompts, instructions, hooks, tools, extensions, settings, skills, MCP, the top-level RULES.md and AGENTS.md files, PROMPT_SECTIONS/, and runtime state (sessions, blobs, agent.db). A profile sees only its own Veyyon config, never the default profile’s ~/.veyyon/profiles/default/agent.
Keybindings get a one-time seed rather than a live merge: a new named profile copies the default profile’s ~/.veyyon/profiles/default/agent/keybindings.* once (at profile new, or on first launch of an older profile that has no keybindings file). After that the profile’s own file is the only one read, later edits to the default profile’s keybindings do not flow into other profiles.
The other source bases are not profile-scoped and load identically under every profile: the external-tool bases (~/.claude, ~/.codex, ~/.gemini) belong to those tools. Throughout this document, read ~/.veyyon/profiles/default/agent as shorthand for the active profile’s agent directory.
Important constraint
The generic helpers in src/config.ts do not include .pi in source discovery order.
2) Core discovery helpers (src/config.ts)
getConfigDirs(subpath, options)
Returns ordered entries:
- User-level entries first (by source priority)
- Then project-level entries (by same source priority)
Options:
user(defaulttrue)project(defaulttrue)cwd(defaultgetProjectDir())existingOnly(defaultfalse)
This API is used for directory-based config lookups (commands, hooks, tools, agents, etc.).
findConfigFile(subpath, options) / findConfigFileWithMeta(...)
Searches for the first existing file across ordered bases, returns first match (path-only or path+metadata).
findAllNearestProjectConfigDirs(subpath, cwd)
Walks parent directories upward and returns the nearest existing directory per source base (.veyyon, .claude, .codex, .gemini), then sorts results by source priority.
This helper predates the untrusted-working-tree rule and survives for the callers that legitimately key on the working directory (plugin install scopes). It is not a path for a repository to configure the agent.
3) File config wrapper (ConfigFile<T> in src/config/config-file.ts, re-exported from src/config.ts)
ConfigFile<T> is the schema-validated loader for single config files.
Supported formats:
.yml/.yaml.json/.jsonc
Behavior:
- Validates parsed data against a provided Zod schema.
- Caches load result until
invalidate(). - Returns tri-state result via
tryLoad():oknot-founderror(ConfigErrorwith schema/parse context)
Legacy migration still supported:
- If target path is
.yml/.yaml, a sibling.jsonis auto-migrated once (migrateJsonToYml).
4) Settings resolution model (src/config/settings.ts)
The runtime settings model is layered:
- Profile settings:
~/.veyyon/profiles/<name>/agent/config.yml - CLI config overlays:
veyyon --config <path>/ repeated--configfiles, loaded asconfig.yml-style YAML for this process only - Runtime overrides: in-memory, non-persistent
- Schema defaults: from
SETTINGS_SCHEMA
There is no project layer. A .veyyon/config.yml or .veyyon/settings.json inside a working tree is never read, because a checked-in file would let any cloned repository configure the agent (the measured escalation was tools.approvalMode: yolo shipped in a repo’s settings.json).
Effective precedence:
defaults <- profile <- CLI config overlays <- overrides
Write behavior:
settings.set(...)writes to the profile layer (config.yml) and queues background save.
Migration behavior still active
On startup, if config.yml is missing:
- Migrate from
~/.veyyon/profiles/default/agent/settings.json(renamed to.bakon success) - Merge with legacy DB settings from
agent.db - Write merged result to
config.yml
Field-level migrations in #migrateRawSettings:
queueMode->steeringModeask.timeoutmilliseconds -> seconds when the old value looks like ms (> 1000). The threshold is a guess, because nothing on disk records which format a file uses, so the rewrite is logged with both values. Every other migration here is a fixed point; this one is not, which is whypackages/coding-agent/test/settings-migration-idempotence.test.tspins the property.- Legacy flat
theme: "..."->theme.dark/theme.lightstructure
5) Capability/discovery integration
Most non-core config loading flows through the capability registry (src/capability/index.ts + src/discovery/index.ts).
Provider ordering
Providers are sorted by numeric priority (higher first). Example priorities:
- Native Veyyon (
builtin.ts):100 - Claude:
80 - Codex / agents / Claude marketplace:
70 - Gemini:
60
Provider precedence (higher wins)
native (.veyyon) priority 100
claude priority 80
codex / agents / ... priority 70
gemini priority 60
Dedup semantics
Capabilities define a key(item):
- same key => first item wins (higher-priority/earlier-loaded item)
- no key (
undefined) => no dedup, all items retained
Relevant keys:
- skills:
name - tools:
name - hooks:
${type}:${tool}:${name} - extension modules:
name - extensions:
name - settings: no dedup (all items preserved)
6) Native .veyyon provider behavior (packages/coding-agent/src/discovery/builtin.ts)
Native provider (id: native) reads native config from one place: the active profile’s agent directory, ~/.veyyon/profiles/<name>/agent/.... The provider’s config-dir helper resolves HOME only. <cwd>/.veyyon used to be pushed at level "project", and six capabilities read it through that one helper (slash commands, rules, prompts, instructions, hooks, tools) plus extension modules and settings; that is gone, because one line in a cloned repo configured the agent. The only thing a repository still contributes is the context-file walk.
Directory admission rules
- The profile agent directory is used only when it exists and is non-empty.
- Skills are loaded only from the active profile’s agent dir (
~/.veyyon/profiles/<name>/agent/skills). Project-local.veyyon/skillsdirectories are deliberately not scanned, so no repository can inject skills into a session by ambient autodiscovery. AGENTS.mdhas three scopes: the global cross-profile~/.veyyon/AGENTS.md, the active profile’s first matching instruction file, and the project walk from the working directory to the repository root (one file per directory level:.veyyon/AGENTS.mdat the nearest non-empty.veyyon/claims its level, bareAGENTS.mdnext, bareCLAUDE.mdlast).RULES.mdis the active profile’s file only; a repository’s.veyyon/RULES.mdis not read. Persistent system-prompt changes usePROMPT_SECTIONS/under the active profile’s agent dir. Seedocs/handbook/src/models/system-prompt.md.
Scope-specific loading
All under the active profile’s agent dir:
- Skills:
skills/*/SKILL.md - Slash commands:
commands/*.md - Rules:
rules/*.{md,mdc} - Prompts:
prompts/*.md - Instructions:
instructions/*.md - Hooks:
hooks/pre/*,hooks/post/*are scanned in full, but only.tsand.jsentries load; anything else is reported as skipped - Tools:
tools/*.{json,md,ts,js,sh,bash,py}andtools/<name>/index.ts - Extension modules: discovered under
extensions/(+ legacysettings.json.extensionsstring array) - Extensions:
extensions/<name>/gemini-extension.json - Settings:
config.yml(plus the one-timesettings.jsonmigration)
Project context-file walk
The native provider’s only project-scope read is the context-file walk: the nearest non-empty .veyyon/ directory’s AGENTS.md claims its own directory level, and every level from the repository root down to the cwd contributes at most one file (.veyyon/AGENTS.md, else bare AGENTS.md, else bare CLAUDE.md).
7) How major subsystems consume config
Settings subsystem
Settings.init()loads the profileconfig.yml, the machine-global bindings, CLI--configoverlays, and runtime overrides. Nothing is read from the working tree.
Session title prompt override
Create TITLE_SYSTEM.md in a supported config base:
# ~/.veyyon/profiles/default/agent/TITLE_SYSTEM.md
Generate a session name using lowercase `<type>:<primary-objective>`.
- Missing
TITLE_SYSTEM.mdkeeps the bundled title prompts. - Discovery checks project config bases first, including
.veyyon/TITLE_SYSTEM.md, then the active profile’sagent/TITLE_SYSTEM.mdand the supported external-tool config bases. - The file replaces only the automatic session-title generation system prompt. The agent’s own base prompt is assembled. Use
--system-promptor--append-system-promptfor a one-run override, orPROMPT_SECTIONS/for persistent section changes. - The online path instructs the title model to wrap the title in
<title>...</title>and parses it leniently from text (a plain sentence, a truncated/unclosed tag, or a stray{"title": "..."}JSON echo all still work). ATITLE_SYSTEM.mdoverride gets the wrap-in-<title>instruction appended after it. The local tiny-title path keeps the<title>...</title>prefill/stop wrapper and uses this file as its system turn.
Skills subsystem
extensibility/skills.tsloads vialoadCapability(skillCapability.id, { cwd, providers: profileSkillProviderIds() }).- The allowlist (
native,veyyon-managed,veyyon-plugins) scopes discovery to the active profile; foreign-tool skill providers are never scanned ambiently (they feed the import scan only). - Applies name-based filters only:
disabledExtensions,ignoredSkills,includeSkills. There are no per-source toggles and no custom directories.
Hooks subsystem
discoverAndLoadHooks()resolves hook paths from hook capability + explicit configured paths.- Then loads modules via Bun import.
Tools subsystem
discoverAndLoadCustomTools()resolves tool paths from tool capability + plugin tool paths + explicit configured paths.- Declarative
.md/.jsontool files are metadata only; executable loading expects code modules.
Extensions subsystem
discoverAndLoadExtensions()resolves extension modules from extension-module capability plus explicit paths.- Current implementation intentionally keeps only capability items with
_source.provider === "native"before loading.
8) Precedence rules to rely on
Use this mental model:
- Source directory ordering from
config.tsdetermines candidate path order. - Capability provider priority determines cross-provider precedence.
- Capability key dedup determines collision behavior (first wins for keyed capabilities).
- Subsystem-specific merge logic can further change effective precedence (especially settings).
Settings-specific caveat
The settings layers deep-merge in a fixed order (profile, then --config overlays, then runtime overrides). Because merge applies later layer values over earlier values, an overlay’s array replaces the profile array rather than appending to it.
9) Legacy/compatibility behaviors still present
ConfigFileJSON -> YAML migration for YAML-targeted files.- Settings migration from
settings.jsonandagent.dbtoconfig.yml. - Settings key migrations include
queueMode,ask.timeout, flattheme,task.isolation.enabled, legacytask.isolation.modevalues, the wholetask.*group plusmodelRoles.taskmoving tosubagent.*, removed edit modes,statusLine.plan_mode,memories.enabled, and hindsight scoping/name fields. - The removed per-source skill toggles (
skills.enableCodexUser,skills.enableClaudeUser,skills.enableClaudeProject,skills.enablePiUser,skills.enablePiProject,skills.enableAgentsUser,skills.enableAgentsProject) andskills.customDirectoriesare no longer read. Skills load only from the active profile. A stale key in an oldconfig.ymlis ignored, not an error.
If these compatibility paths are removed in code, update this document immediately; several runtime behaviors still depend on them today.
MCP
Model Context Protocol (MCP) connects Veyyon to external tools and data as an MCP client
(consumes configured servers). Editor embedding uses ACP (veyyon acp), a different protocol.
Responsibility
- Discover MCP servers from the operator’s user and profile config files
- Connect over stdio or HTTP (streamable HTTP / SSE-style transports)
- Register tools as namespaced names (
mcp__<server>_<tool>, e.g.mcp__filesystem_delete) - Handle OAuth for remote servers and persist credentials per profile
Implementation (TypeScript)
| Module | Role |
|---|---|
packages/coding-agent/src/mcp/ | Config load, manager, OAuth, tool wiring |
packages/coding-agent/src/discovery/builtin.ts | Profile-scoped mcp.json / .mcp.json discovery |
packages/coding-agent/src/modes/controllers/mcp-command-controller.ts | /mcp TUI commands |
Primary config files:
- User:
~/.veyyon/profiles/default/agent/mcp.json(profile-scoped when using--profile)
There is no project scope. A checked-out working tree is untrusted input, so
<cwd>/.veyyon/mcp.json, a repo-root mcp.json/.mcp.json, and the foreign
.cursor/mcp.json and .vscode/mcp.json are no longer read.
Veyyon also ingests MCP definitions from other tools’ USER-level configs
(~/.claude, ~/.codex, ~/.gemini, ~/.cursor) when discovery is enabled.
A config file that exists but does not parse is REPORTED, never skipped: the
native provider raises Failed to parse JSON in <path> through the capability
warning channel, which MCPManager.discoverAndConnect puts on its status stream
and /mcp list and the boot health zone render. Before that, a mistyped comma in
mcp.json produced a session with every configured server missing and no line
anywhere saying why.
Engineering detail:
docs/handbook/src/reference/mcp-config.md,
docs/internal/mcp-runtime-lifecycle.md,
docs/internal/mcp-protocol-transports.md.
Providers
The providers subsystem connects Veyyon to model APIs and normalizes their auth, request, and response formats.
Responsibility
- Maintain the catalog of supported model providers and their capabilities.
- Resolve a model slug to a provider and its
ModelInfo. - Authenticate requests with API keys, access tokens, or OAuth credentials.
- Translate between the provider-specific wire format and the engine’s protocol types.
Implementation
The provider stack lives in the @veyyon/ai package.
| Component | Role |
|---|---|
| Provider adapters | Per-provider connection and wire-format adapters |
| API client registry | OpenAI-compatible API client registry |
| Provider details | Provider metadata, auth mode, and endpoints |
| Model catalog | Model catalog and per-model capabilities |
| Model registry | Slug resolution to provider + model info |
Key concepts
- Provider metadata: a provider’s auth mode and endpoint configuration.
- Model info: per-model capabilities such as context window and vision support.
- Auth material: resolved from API keys, access tokens, or OAuth credentials.
See Models and providers and Provider stack and bring-your-own-key for how to add your own keys and choose models.
Prompt caching
Each provider adapter also sets where the request’s cache markers go, and the shapes differ:
Anthropic places up to four cache_control breakpoints, Bedrock interleaves cachePoint blocks,
the OpenAI Responses path sends an explicit prompt_cache_breakpoint, and everything else caches
implicitly or not at all. Two settings under Settings → Context → Prompt Cache report and
optionally block on a cache the provider demonstrably did not use. The full per-provider account,
including the breakpoint budget and what invalidates what, is
docs/internal/prompt-caching.md.
The first-event budget
A caller declares streamFirstEventTimeoutMs. It is one attempt’s deadline, and
it bounds how many stalled attempts a turn pays for: the phase before the first
event ends after two. The first stall is retried, because a single connect that
never produces an event is common and recoverable. A second consecutive stall is
a dead endpoint, and re-spending the deadline there turned a declared 100s into
minutes of silence.
The phase ends at the first of these:
- the first event arrives, after which
streamIdleTimeoutMsbounds the turn; - the phase budget is spent and the turn fails with the deadline as its reason.
utils/first-event-budget.ts in @veyyon/ai defines the shape:
| Function | Use |
|---|---|
openFirstEventBudget(totalMs) | Open a budget for the declared number. A non-positive or absent total is unbounded, matching streamFirstEventTimeoutMs: 0. |
openStallLadderBudget(perAttemptMs) | A phase budget for a retry ladder: the per-attempt deadline times PRE_RESPONSE_STALL_ATTEMPTS (two). What Anthropic and Codex open. |
openBoundedFirstEventBudget(declaredMs, ceilingMs) | The smaller of the caller’s number and a provider’s own ceiling. It can only tighten a deadline. |
budget.spent() | True once nothing is left. A retry ladder checks it before retrying a stall. |
budget.fence(callerSignal) | A signal covering what remains, plus the cancel() that clears its timer. A setup chain fences once and passes that signal to every call. |
isPreResponseStall(error) | True when no byte of a response ever arrived. |
Four rules keep this narrow.
A stall is bounded by the budget; a server-directed wait is bounded by the
cap. A 429 or 503 carrying retry-after is the server answering and asking for
a later attempt, so it is not rejected when the first-event budget is gone. Only a
failure where nothing arrived at all is rejected, which makes the guard a veto
predicate rather than a fence around a retry loop. The wait itself is bounded
separately: maxRetryDelayMs is the longest single server-directed wait a caller
sits on, DEFAULT_MAX_DELAY_MS (60s) when it declares none, and a hint above
that cap surfaces the refusal instead of sleeping on it. Every retrying path
reads the caller’s number: fetchWithRetry for the OpenAI-compatible family,
Bedrock, Ollama and Codex, and the Anthropic client and provider ladder for their
own retry-after-ms handling.
A deadline that fires ends the phase. A helper that degrades on its own timeout (GitLab Duo’s settings PUT, its project lookup, its model list, and the catalog namespace reader behind them) rethrows when the caller’s deadline is what fired. Reporting it as “nothing found” and continuing spends time nobody granted and reaches the user as a configuration remedy for a network fault.
A refusal states its remedy. 401, 404, 429 and 400 have four different
answers: fix the credential, fix the route or the model id, wait, fix the
request. Two shapes collapse them into one wording. A status read for a debug log
and then discarded: Cursor’s Connect stream did this, so every refusal arrived as
“stream ended without a turn_ended update”. A handshake that treats a refusal as
one candidate’s silence and reports the remedy for having found nothing: GitLab
Duo’s namespace walk did this with a rejected token.
A stream that stopped is not a stream that finished. Every dialect ends a
turn with its own marker: finish_reason and [DONE], response.completed,
message_stop, finishReason, done: true, messageStop, turn_ended. The
end of a body without one is a transport-clean EOF that indicates nothing about the
turn. Reporting a normal stop there persists whatever arrived as an answer, and
the model reads it back as history on the next turn. Rejecting every such EOF
fails turns that were complete, because several compatible servers do not send
the marker. stopReasonForTerminallessEof in utils/terminalless-eof defines the
judgement for every dialect: visible text is a stop, reasoning with no answer is
a length the session can recover, a tool batch counts only when every call
parsed, and anything else is an incomplete-stream failure. A provider that
seeds stopReason: "stop" before the first byte and never consults this rule
writes a blank turn into the session, as Bedrock and Ollama did.
Where each provider’s deadline sits
| Provider | Bound before the first event |
|---|---|
| OpenAI completions, Responses, OpenRouter, Azure | Pre-response fence plus the stream watchdog. |
| Anthropic | The same, and the retry ladder retries one stall and rejects the next once the phase budget is spent. |
| Codex | The same, on both ladders: fetchWithRetry (no response) and the provider-error reopen (a retryable envelope that is itself a stall). |
| GitLab Duo | One setup deadline over the whole REST chain: the caller’s number, or 90s (three REST timeouts), whichever is smaller. |
| Bedrock, Google, Vertex, Gemini CLI, Ollama, Cursor, Devin | The registered lazy-stream limits and each transport’s own abort. |
packages/ai/test/no-api-outlives-the-budget-its-caller-declared.test.ts drives
every API in the union against a silent endpoint and pins the observed class per
API, so a provider that stops honoring the number turns that suite red.
packages/ai/test/every-provider-refusal-names-what-to-do-about-it.test.ts
drives the same fourteen against a refusing transport — 401, 404, 429 with
a two-minute retry-after, and 400 — and pins the class each one surfaces,
plus the invariant that no refusal echoes the api key back into its message.
packages/ai/test/a-stream-that-stops-mid-turn-is-never-reported-as-a-finished-one.test.ts
drives all fourteen against a 200 that closes without a terminal marker and
pins each verdict, so a dialect that starts accepting an empty stream as an
answer turns red rather than shipping a blank turn.
Secrets internals
How secret protection is implemented: the modules, the placeholder grammar, the command shapes, and the vault on disk. The operator guide is Secrets.
Sensitive values (API keys, tokens, passwords) are kept out of LLM provider requests. When enabled, secrets are replaced before any provider-bound prompt, message, schema, replay payload, or nested model request leaves the process. Reversible placeholders are restored for local display. A resumed transcript is sanitized again before it is sent.
Enabling
Disabled by default. Storing a credential with /secret turns it on for you, because storing one for the agent to use is the opt-in, and the confirmation reports it. To turn it on without storing anything, use the /settings UI or config.yml directly:
secrets:
enabled: true
Nothing turns it back off on your behalf. Revoking a credential removes it and leaves protection where it is.
How it works
-
Secrets are collected from three sources at startup and whenever the live secret runtime is refreshed:
- Environment variables whose names match a keyword from
secrets/env-keywords.yml(KEY,SECRET,TOKEN,PASSWORD,PASS,PASSPHRASE,AUTH,CREDENTIAL,PRIVATE,OAUTH), with values at least 8 characters. Tier B data: a keyword file at<agent dir>/secret-env-keywords.ymlor<cwd>/.veyyon/secret-env-keywords.ymladds to the list and cannot remove from it. See Env keyword list. secrets.ymlfiles (see below).- Encrypted vault entries selected for the current profile and working directory.
- Environment variables whose names match a keyword from
-
Outbound strings are replaced before provider dispatch. Named vault values use readable placeholders such as
#GITHUB_TOKEN#. Unnamed values use a stable machine-keyed HMAC placeholder such as#0A1B2C3D4E5F678901234567#. The keyed form is stable across restarts without exposing an index or an offline dictionary oracle.
The final provider boundary works from raw strings before trimming, truncation, serialization, or other lossy transforms. It resolves the live runtime for every physical attempt, including authentication retries, fallback models, delayed queues, compaction, commit analysis, evaluation, benchmarks, memory services, TTS, and image tools. JSON object keys and values are both covered, and key collisions fail closed.
Opaque authenticated replay fields are validated rather than mutated. A live secret in a signature, provider item id, encrypted reasoning block, or provider payload rejects dispatch with a value-free error. Provider-bound images are content-detected, decoded, and canonically re-encoded so EXIF, comments, and other container metadata cannot bypass string obfuscation. URLs that appear to carry credentials bypass cloud reader and enrichment services.
-
Local display restoration expands only live reversible placeholders. Replace-mode substitutions are one-way. Expired and removed values lose expansion rights but retain forward redaction tombstones, so old transcript text cannot become provider-visible.
-
Toggling secret protection and running
/secretcommands rebuilds the runtime immediately, and the system-prompt inventory of spendable names with it. A working-directory move loads the destination project scope transactionally and drops the source project’s mappings. If loading fails, both the old directory and runtime are restored. Persisted subagents and resumed sessions initialize from their recorded directory. A same-directory refresh retains only forward redaction history for removed values.
Spending a secret prompts first
Substitution runs on tool arguments just before a tool executes, so the model can put
#GITHUB_TOKEN# in a shell command and Veyyon supplies the credential it never showed the model.
That is recorded by secrets.auditLog, which answers “which credential did this agent use, and
where” after the fact.
A call whose arguments carry a real credential also needs approval, in the same modes as the
working-directory boundary: plan, ask, and auto-edit. The prompt states the secret and never
shows its value, and it is added to whatever the tier already required, so it can only require more
approval and never less. yolo opts out of all permission and opts out of this with it, so the
shipped default requires nothing extra. An unknown placeholder contains no credential and does not prompt.
If the name was advertised earlier in this process and expansion is later removed or disabled, the
tool call is rejected before approval instead of running with stale literal text. See
Approval modes.
What the session file records about the call
Veyyon writes one diagnostic entry when a tool starts, so a session that dies mid-call can tell you
on resume which call was still running. The entry keeps a truncated copy of the command or path
argument and the model’s stated intent.
Those arguments are the expanded ones, because expansion has already happened by then. They are
redacted before the entry is written, so the session file records printf '%s' '#GITHUB_TOKEN#'
and never the credential. Redaction runs before truncation, so a value sitting across the
200-character cut cannot leave a readable prefix behind. The redaction survives a /secret disable:
the tombstone that keeps an old value hidden from providers keeps it out of this entry too.
The full arguments the model wrote are persisted with the assistant message, and those hold the placeholder, since the model never saw anything else.
What the command printed is a different matter. Tool output is saved as it was printed and redacted on its way to the provider, not on its way to disk, so a command that echoes a credential puts it in the session file. Veyyon redacts what it records itself; it cannot redact what a command chose to print.
Two modes control what happens to each secret:
| Mode | Behavior | Reversible |
|---|---|---|
obfuscate (default) | Replaced with a named or machine-keyed HMAC placeholder | Yes, while the entry is live |
replace | Replaced with a deterministic safe same-length string | No |
The 8-character minimum
obfuscate mode replaces every occurrence of the value, so a very short secret would blank out fragments of ordinary words. Values under 8 characters are therefore rejected rather than protected, and the refusal is loud:
- A plain
obfuscateentry under 8 characters stops startup with an error stating the entry and the fix. It is not skipped. Skipping it would send the value to the provider while the file stated otherwise. - Use
mode: replacefor a short value. Replace is one-way, needs no reversible placeholder, and has no minimum. - A regex match under the floor is skipped rather than rejected, because a short match usually means the pattern reached into ordinary prose. The skip is recorded once per pattern so you can see that the pattern is over-matching. If short matches are genuinely secret, set
minLengthon that entry.
An unreadable or malformed secrets.yml also stops startup. A missing file does not: nothing was declared, so there is nothing to protect. The distinction matters because reading a broken file as “no secrets” starts a session that believes it has nothing to hide.
Per-entry validation is a refusal, not a skip
validateEntry rejects a malformed entry instead of warning and dropping it. The default transport set is { file: true } with no console transport (logger.ts:219), so a warn-and-drop hides the fault in a log file and sends the credential the operator declared to the provider in plain text.
Problems accumulate and are reported together, so three typos cost one restart. The message states the entry index, the field and the fix. It never quotes the offending content: on a plain entry that content is the credential.
Unknown fields, and fields that do not apply to an entry type, are errors. Regex declarations also reject duplicate or incompatible flags, sticky or zero-width matching, and conservatively detected catastrophic-backtracking forms. These checks run before a session can send provider traffic.
The vault (/secret)
Two stores feed the obfuscator. secrets.yml below is declarative and plaintext. The vault is imperative and encrypted: entries are added at runtime with /secret, are named, and expire.
Two grammars, selected by surface
parseSecretCommand(args, surface) reads one of two grammars, and surface alone selects between them. runSecretCommandForSurface passes "noninteractive" when port.promptForValue is absent, which is the case where the client cannot hide what is typed, and "tui" otherwise. The branch is on the surface and never on the shape of the input, so nothing an operator types moves them from one grammar to the other.
Both grammars require a command first, and what differs is where the value may come from. A first word that is not a command is nothing:
| Typed | Parsed as |
|---|---|
/secret | { subcommand: "help" }, on both surfaces. |
/secret <anything unreserved> | rejected. Nothing is stored, and the refusal never repeats the word. |
/secret <reserved word> ... | that subcommand, or a refusal when the rest of the line does not fit its shape. Never a credential. |
/secret add | { subcommand: "add" }. needsValuePrompt is then true, so the surface opens the masked field. |
/secret add <anything> | { subcommand: "add", value }, sliced from the first token’s start to the last token’s end. A credential may therefore begin with a reserved word. |
/secret add -- ... | rejected, stating the plain word that replaced --. Nothing is stored. |
/secret from-env VAR [NAME] [7d] [project] | { subcommand: "from-env", fromEnv, name?, ttl?, scope? }. Its own command, not a modifier on add. The name is required on a client and optional in a terminal, where a field prompts for it. |
The slice rather than a trim drops the whitespace a terminal adds around what was typed and preserves, byte for byte, any whitespace inside the credential, because a passphrase may contain spaces.
A value is read in exactly one place, after add. The reserved words are the keys of SECRET_VERB_SPELLINGS: every canonical subcommand plus the second spellings env, remove, delete, wipe, purge, empty, reset, name, replace, move, renew and audit. They are a list of what runs, not a list of what a value may not start with. A reserved word whose remainder does not fit its shape is rejected, because falling back to storage would turn /secret log 50 into a stored credential reported as a success.
The refusal states the exposure, and never the bytes. A terminal refusal states that nothing was stored, that the line is exposed, and to rotate the credential, and never echoes the word, because that word is very often the credential. The noninteractive refusal drops the scrollback sentence: its line came from argv rather than a screen.
-- is rejected as the first word after add, stating the plain word that replaced it, rather than passed to the value reader, which would slice -- sk-live-x verbatim and store a credential with the dashes attached. That failure is invisible until the credential is spent, and then surfaces as an authentication error with nothing connecting it to a slash command. The match is on the whole first word after add, so a value that merely begins with dashes, a PEM block for instance, is stored byte for byte.
No /secret command takes an option. --from-env, --ttl, --scope, --limit and --name are rejected, each stating the plain word that replaced it. A word is read by the POSITION it sits in, or by a CLOSED SET or SHAPE it belongs to. Position covers every required word, so /secret rm PROFILE removes the secret named PROFILE. Shape covers trailing words that may be omitted or reordered, and only where the sets cannot overlap: a vault is one of exactly three words, a lifetime is isTtlWord or any digit-leading word, a limit is digits only, a secret name may not begin with a digit and may not contain a hyphen. Each slot states its own disjointness proof in SECRET_SUBCOMMAND_SHAPES.
from-env is a command of its own rather than a modifier on add, and it takes the lifetime and the vault that the value forms cannot: /secret from-env DEPLOY_KEY DEPLOY_TOKEN 30m project is a complete store. A terminal add takes the secrets.defaultTtl lifetime and the default profile vault, because everything after add is the credential; /secret extend sets a different lifetime afterwards and /secret scope moves it, from the same prompt.
The terminal form takes no name. /secret add <name> <value> had no unique reading once the value was arbitrary text, and /secret add ghp_realToken stored a live credential as a NAME with no value attached. The value is the whole line, and the name is prompted afterwards.
The name is prompted last. runSecretCommandForSurface calls port.promptForName() once a value is in hand, for a pasted value, a masked one and a from-env one alike, because all three arrive without a name. That field is visible: a label is not a credential, and maskedPromptTitle states “value, not a name” because the masked field is the one place the two can be confused. An empty answer keeps the generated name. Escape abandons the store rather than falling back to a generated name.
One asymmetry. A client with no terminal cannot accept a credential the caller types, so add is rejected there and from-env requires the name a terminal prompts for in a field afterwards. Everything else parses identically:
| Subcommand | Purpose |
|---|---|
/secret from-env <VAR> <name> | Store the value of an environment variable. The credential is never typed. The only entry form a client with no field accepts: an inline value is rejected, because it would be retained in the client’s request history. In a terminal the name may be omitted and is prompted afterwards. |
/secret list | An aligned table of placeholders, scopes and lifetimes, plus a STATUS column when a row is near expiry. Never values, not even a prefix. |
/secret rm <name> | Remove the entry that is currently in effect, and tell the model that its placeholder is revoked. |
/secret rename <name> <new-name> | Relabel an entry, keeping its value, creation time and expiry. Refused when the new name is taken. |
/secret value <name> | Replace an entry’s value, keeping its name, scope, creation time and expiry. Takes a masked field, or the trailing pair from-env <VAR>. |
/secret scope <name> <scope> | Move an entry to another vault. Refused when the destination holds that name, and what moves is the lifetime REMAINING. |
/secret copy <name> | Hand the surface #NAME# to put on the clipboard. Never the value. |
/secret extend <name> 7d | Give an entry a fresh lifetime, measured from now, and tell the model the placeholder is still live. |
/secret log [<name>] [<limit>] | The expansion log: which placeholder went into which command, when. A name narrows it to one credential; a number sets how many records. Either order, because a name may not begin with a digit. |
/secret discard <vault> | Move one vault’s unreadable file aside so that vault works again. Never deletes it. |
A word neither grammar reserves is rejected where a value cannot be typed, and the refusal prints the whole usage without repeating the word: the caller cannot open a help screen, and the unknown first token is very often the credential itself.
Both grammars produce the same SecretCommandRequest and run through the same runSecretCommand, so the two cannot drift into different ideas of what a lifetime or a scope means. secretCommandUsage(surface) picks help to match, and the credential-entry lines are the only text the two help outputs disagree about. They are named once rather than written out twice: a surface with no way to hide what is typed must never advertise typing a credential.
Each slot belongs to the commands that read it, and SECRET_SUBCOMMAND_SHAPES is the one owner of that mapping:
| Word | Read by | How it is recognised |
|---|---|---|
| an environment variable | from-env (position 1), value (after from-env) | position, and a keyword on value because a variable name is arbitrary text |
| a name | every command that takes one entry | position, except on log where it is the non-numeric trailing word |
| a lifetime | from-env, extend | 30m, 12h, 7d, 2w, never, or any digit-leading word |
| a vault | from-env, rm, clear, scope, discard | one of profile, project, global. Position on clear, scope and discard; trailing on from-env and rm |
| a limit | log | digits only, and a safe integer |
SECRET_SUBCOMMAND_SHAPES also records how many words each command reads and which are required: one for rm, value, copy, clear and discard, two for rename, scope, extend and from-env, none for list, log and help, and unbounded for a terminal add. add is unbounded because the whole line after it is rejoined into the credential and a passphrase contains spaces, so a word count would reject /secret add gpg my long pass phrase as five arguments when it is two. clear and discard read one word and it is a vault rather than a name, because each acts on a whole file.
A word a command does not read is rejected, stating the position it arrived in. An earlier grammar parsed every option for every verb and let each subcommand read only the fields it cared about, so /secret extend NAME --scope global reported success and did nothing about the scope, and /secret rm NAME --scope project read as “the project copy is gone” when the copy in effect had been removed and the others were untouched. The rule covers plain words too: /secret extend TOKEN global is rejected rather than re-dated with the vault word ignored.
The refusal states the POSITION and never repeats the word. The common slip is muscle memory for add under another command (/secret extend TOKEN sk-live-..., /secret rm TOKEN sk-live-..., a value appended to /secret list), so the extra word is very often the credential, and quoting it would write that credential into the scrollback and the saved transcript permanently. A digit-only word is echoed, because a number cannot be a credential and the echo is what makes the hint useful: /secret rm TOKEN 50 can then state what a bare number would have meant. In a terminal the refusal also states the value form.
needsValuePrompt sets whether a surface prompts, and it lives in the pure command layer so the TUI and text/ACP paths cannot disagree about when a masked field is warranted. A surface that cannot mask must not substitute an unmasked prompt: absent promptForValue, runSecretCommand rejects the add and names from-env. That same absence is what selects the grammar, so a client is never offered a field it cannot open.
discard: the repair for a vault that cannot be read
load() skips a scope whose file exists and cannot be read, with a notice, and remove() will not touch one. Between them the operator could start and could not repair: discardUnreadableScope had no caller, so the only route was deleting the file by hand. /secret discard <vault> is that route, and it parses on every surface, so one notice states one repair whichever client prints it.
It moves the file to a vault.json.unreadable-<timestamp>-<uuid> sibling rather than deleting it. The file still holds real credentials, sealed with a key that is still on disk, so the damage may be a truncated tail with recoverable entries behind it. The new path is returned and printed: it is the operator’s only route back to those entries, and a message that omitted it would make a recoverable move indistinguishable from a delete.
The vault is required here and defaulted everywhere else, the one exception in the table above. Elsewhere the word states where to PUT something, and /secret list shows a wrong guess. Here it selects a file to move aside, so a default would let a bare /secret discard move a working vault out from under the session. It sits at position 1 rather than trailing, because discard takes no name. The refusal contains the usage and states that there is no default, and the guard is repeated at the dispatch as well as the parser, because ACP and other adapters build a request object without going through parseSecretCommand.
Two refusals:
- A scope that reads normally. Checked inside
discardUnreadableScope, under the file lock, rather than trusted from an earlierload(), because the file may have been repaired in between. The refusal names/secret rm <name>, which can state what it removed. - A scope whose path is also another scope’s vault. A profile directory that is the config root makes the profile and global vaults one file, so moving it aside as one takes the other with it.
#scopePathOwnerresolves the owner by file identity and the refusal states it, because an operator told only “cannot discard” usesrmon the file and loses both.
Afterwards the result carries changed: true, so the surface rebuilds the obfuscator: the scope’s file has stopped existing at the path the loader reads, and until it reloads the session holds the pre-discard view. The moved-aside file keeps mode 0600.
Masked entry
Input.mask on the shared TUI component is the single place a value becomes something a terminal can show, so masking is one projection applied in render rather than a second text field. maskValue emits one mask character per grapheme and maps the cursor to the grapheme count before it, so an astral character or a combining sequence counts once. getValue still returns what was typed: masking the buffer itself would store a row of bullets as the credential.
The masked prompt is showHookInput, which is local only. Unlike the selector and editor dialogs it is never raced against a collab guest, so a masked field cannot be answered from another machine.
request.maskedEntry records that a value came from the field rather than the command line, and only the confirmation text depends on it. A scrollback warning that fires when it does not apply is one an operator learns to skip, including on the inline path where it is true.
Named and unnamed placeholders
A vault entry’s placeholder is its name, so the model sees #GITHUB_TOKEN#. That is what lets it choose between several credentials deliberately, and it makes the placeholder stable across sessions.
Names are 5 to 64 characters of A-Z, 0-9 and _, starting with a letter. Unnamed HMAC placeholders start with the reserved digit 0, so a name can never collide with one. normaliseSecretName accepts what people type (github-token, github token, lowercase) and uppercases it. It rejects non-ASCII input before uppercasing, so Unicode case expansion cannot alias an existing name.
Entries without a name get a generated name (SECRET_1), so every vault entry has a placeholder the model can reference. Plain environment and secrets.yml values use the machine-keyed unnamed form.
Completing /secret
Argument completion offers the subcommands and nothing else, derived from SECRET_TUI_SUBCOMMANDS, which the parser builds from the same table it routes with. A verb cannot be typeable and unoffered, and a word cannot be offered and unparseable. The operator-facing account is Managing what you stored.
No stored NAME is ever offered. Completing one from session.obfuscator.namedSecretNames() renders part of the vault on a keystroke, and accepting a suggestion writes it onto a line whose first word decides between a command and a credential, so a fumbled verb stores the suggestion instead of running it. /secret list is where names are read.
The prefix filter keeps the menu out of a paste. A pasted credential arrives as one insert, so the prefix is the whole token and matches nothing; only a hand-typed word that is the start of a subcommand opens the dropdown. Nothing about the vault is read to build it, so completion still works when secret protection is off.
What the model is told about a stored secret
The operator-facing account is What the agent knows, and when. Two mechanisms carry it, with different jobs.
The inventory is a system-prompt section. SecretObfuscator.namedSecretNames() returns every readable name the live runtime can expand, sorted, and never a value. It calls #forgetExpired() first, so a name stops being answered at the moment it stops working. That list becomes an optional option-backed runtime section registered in RUNTIME_SECTIONS (system-prompt-builder/section-registry.ts) and supplied where sdk.ts calls the system-prompt builder, beside secretsEnabled. AgentSession.refreshSecrets() reloads the runtime and rebuilds the base prompt, which is what makes a removed or expired name stop appearing.
Sorted because the section sits in the cached prompt prefix. Map insertion order would shuffle between refreshes and invalidate the provider’s prompt cache without changing the section text.
An optional section renders only when its option is present, so protection being off, or nothing being spendable, produces no section rather than an empty heading. Names are listed in placeholder form, which is the form the model has to write. Index-form secrets are absent, having no name to list.
It belongs in the prompt rather than in the conversation because the vault outlives the conversation. Vault entries are profile, project or global scoped and persist across sessions; a notice injected into history does not. Before this, a credential stored yesterday was live this morning and unknown to the model that could spend it.
The notice is a developer message. runSecretCommand returns agentNotice for add, rm and extend; list, log and help return none. tellTheAgent (slash-commands/helpers/secret.ts) appends it to the live agent and to the session file, because only the first leaves a resumed session holding a placeholder it was never introduced to, and only the second withholds the news until the next restart.
rm states the revocation rather than leaving it to the name’s disappearance from the inventory. A model does not reliably notice an absence. The tool boundary also keeps the exact retired placeholder name in memory and rejects a later attempt to spend it, while unknown text such as #TODO# remains ordinary input. The removal notice is still delivered even when secrets.enabled is off because it reaches the model and persists in resumable history; the boundary is the local backstop that prevents an ignored notice from becoming a confusing remote authentication failure. A revoked placeholder is already in the history; a new one with protection off has nothing to expand into.
No notice contains a lifetime. A duration is accurate when written and wrong afterwards, and the operator reads the exact time left from the terminal confirmation instead.
Expiry that no command triggered has no notice at all. The name leaves the inventory on the next rebuild, #forgetPlaceholder has already revoked expansion, and the operator hears about it through OperatorNotices.
No path puts a value in front of the model. The inventory carries names, the notices carry names, and substitution happens after the model has written the placeholder.
Storage
| Scope | Path |
|---|---|
global | ~/.veyyon/vault.json |
profile (default) | <agent dir>/vault.json |
project | <cwd>/.veyyon/vault.json |
Narrowest scope wins a name clash. rm and extend walk scopes narrowest-first, so they act on the entry list shows.
Encryption is AES-256-GCM with a fresh 12 byte nonce and a full 16 byte authentication tag per write. The key is 32 random bytes at ~/.veyyon/vault.key, created on first use and never stored inside a project tree. On POSIX, the key is mode 0600 and its directory must be owned by you and not writable by another user. On Windows, Veyyon applies and verifies a protected owner-only ACL.
Vault updates use a synchronized owner-only temporary file. Kernel no-replace and exchange operations publish the synced inode without overwriting a destination that appeared after the last check. Each transaction keeps the scope directory open and performs file I/O through that descriptor. Replacing the lexical directory during a transaction therefore causes a hard error instead of redirecting the read or write.
Read and write paths reject symlinks, hard links, directories, devices, insecure permissions, and other non-regular files. Scope checks resolve real parent directories. The authenticated location includes the semantic scope, canonical path, and physical scope-directory identity. Copying or backing up vault.json preserves confidentiality, but the ciphertext is not a portable restore artifact. Store those entries again after moving or recreating the scope directory.
The sealed descriptor is limited to 8 MiB before allocation. Writes also enforce a 6,291,402-byte encoded plaintext limit before JSON serialization, encryption, or Base64 expansion.
Failure behavior is fail-closed:
| Condition | Behavior |
|---|---|
| No vault file | Empty. Nothing was stored. |
| Vault present, key missing | Hard error. Never read as an empty vault. |
| Key of wrong length | Hard error, so a new key is not written beside a recoverable one. |
| Unsafe key directory, key file, or vault permissions | Hard error stating the permission fix. POSIX ownership and modes and Windows owner-only ACLs are checked. |
| Symlink, hard-linked file, or non-regular key/vault path | Hard error. The path is never followed or shared. |
| Ciphertext, nonce, authentication tag, scope, canonical path, or physical scope identity modified | Hard error. GCM authenticates the complete envelope and its location. |
| Legacy version 1 envelope | Hard error directing the operator to re-add the entry in the bound current format. |
| Unknown envelope version | Hard error advising an upgrade rather than deletion. |
| Entry name or value contains ill-formed UTF-16 | Hard error before a write, or after authenticated decryption during a read. Existing ciphertext is left unchanged. |
Lifetimes
secrets.defaultTtl sets the default (1d). An absent setting uses the built-in default; a setting that does not parse is an error rather than a silent fallback.
Expiry has two ordered effects. At use time, the live obfuscator revokes placeholder expansion and installs a forward-only HMAC tombstone for the old raw value. This prevents a transcript containing that value from becoming provider-visible. The hot path performs no vault I/O, so the encrypted entry remains on disk until the next successful vault refresh prunes it.
Expiry is enforced at use time as well as at load:
- The check sits on
deobfuscate,hasNamedSecretandknowsPlaceholder, so no path reaches a value without passing it. #nextExpiryAtcaches the soonest deadline, so the hot path is one number comparison and the map is scanned only when a deadline is crossed.- A lapse calls
onExpirywith explicit persisted-deletion state.sdk.tsrenders an operator notice that states expansion was revoked and, until a vault refresh succeeds, that encrypted ciphertext remains. - A successful vault refresh prunes expired entries before rebuilding the runtime.
addNamedSecrettakes the deadline, so/secret extendmoves the moment substitution stops.#forgetPlaceholderis the one owner of revoking reverse mappings and installing forward redaction tombstones.
WARN_AT_FRACTIONS ([0.5, 0.9]) is the single owner of when a warning fires, as fractions rather than absolute times so one rule serves 1d and 90d alike. expiryWarnings consults warningThresholdCrossed rather than doing its own comparison: it previously held an inline 0.9, which meant two owners disagreeing and a halfway warning that could not fire. The wording reads the urgent threshold off the end of the list for the same reason, so adding a 0.99 would not leave a secret with minutes left described as “over halfway through its lifetime”. expiryUrgency wraps that one comparison and classifies an entry as soon or halfway, so the STATUS column in /secret list reads the same thresholds as the warnings rather than becoming a third owner of the question. Warnings are raised at session startup through OperatorNotices, and the channel collapses repeats so a long-running session is told once. Each line states the /secret extend command that prevents the loss, since expiry is not recoverable after the fact.
The expansion log
secrets.auditLog (default on) records each tool call that mentioned a secret, one JSON object per line, to <profile dir>/secret-audit.jsonl.
| Field | Meaning |
|---|---|
at | Epoch milliseconds at expansion. |
secrets | Placeholders substituted, in order of appearance, deduplicated. |
tool | Tool that received them. |
session | Session id. Omitted when the session has none yet. /secret log states how many distinct sessions the shown records came from, since the log is per-profile and two windows append to one file. |
command | The arguments as the model produced them, JSON-encoded. |
truncated | true when command was cut to fit the byte cap. |
omittedSecrets | Number of additional placeholder references omitted to keep the encoded record under the byte cap. |
Written from the arguments before substitution, which is the form in which every secret is still a placeholder. That ordering is the safety property: there is no redaction step to get wrong and no way for a value to reach the file. buildExpansionRecord receives the pre-expansion arguments and nothing else.
MAX_RECORD_BYTES (2048) is a security and concurrency boundary. Every field and placeholder list is bounded before encoding. Placeholder discovery walks JSON string values and object keys in the same order as expansion. A cross-process file lock covers the size check, atomic rotation rename, append, and generation reads, so two sessions cannot overwrite a rotated generation or push a record past the cap.
Failure behaviour differs from the vault’s, deliberately. Obfuscation is the preventive control and it fails closed; the log is a detective control, so a failed append raises an operator notice and the command still runs. Refusing to execute a tool because a log file could not be written turns a full disk into an agent outage while nothing is actually unsafe. What is not permitted is silence: a log that stopped recording must not look like a log with nothing to record.
The log is written in the profile directory, never the project one, and is written 0600. It states which credentials exist and when they are used, which is reconnaissance even without values.
Three properties hold:
- Rotation.
ROTATE_AT_BYTES(2 MiB, about ten thousand uses) atomically moves the file tosecret-audit.jsonl.1and starts a fresh one, keeping two generations. The same cross-process lock covers both sessions that race at the boundary.readspans both generations, so/secret log 20immediately after a rotation still answers with twenty records. - Full validation on the way back in. A parsed line is accepted only when every field the renderer reads has the right type. The check was
typeof at === "number" && Array.isArray(secrets)followed by a cast, so a line missingtoolprintedundefinedin the middle of a security report. Anything that fails is counted as malformed and the count is shown, never dropped. Terminal control characters in records, paths, and notices are escaped before display. Hard-linked generations are rejected, and the 2 MiB generation limit is checked before allocating a read buffer. - Flushed on dispose. Appends are queued so a tool call is never blocked by a write, which means an exit that does not drain the queue loses records silently. the session’s
dispose()awaitsflush(), because quitting ends the process rather than waiting for pending work, and the last credential used is the one an incident concerns.
Operator notices
OperatorNotices (session/operator-notices.ts) is the one channel for a non-fatal problem the operator must see. It exists because there was none: logger.warn writes to a file with no console transport, and AgentSession.skillWarnings was a getter that production code never read, so skill-loading problems were discarded silently while the field looked like a surface. Both now route here.
Notices buffer until a sink attaches, because they are raised while a session is being built and the TUI does not exist yet. Interactive mode passes a sink-less collector to createSession and attaches its own after the first render; every other mode uses the default, which writes to stderr as notices arrive. A caller that attaches nothing gets its notices in the wrong place, never dropped.
Identical notices collapse on severity + source + text, keeping the first timestamp. A problem detected once per turn would otherwise train the operator to ignore the channel, which ends in the same silence by another route.
Env keyword list
secrets/env-keywords.ts defines the keyword list and the boundary rule; nothing else matches an environment variable name. The list was an inline regex in secrets/index.ts and is Tier B data now, so an operator can extend it without editing source.
The boundary rule is (?:<keyword>)(?:_|$), case-insensitive: a keyword matches only where it ends the name or is followed by an underscore.
| Candidate | Decision | Reason |
|---|---|---|
PASSPHRASE | added | The one genuine gap. GPG_PASSPHRASE matched only because of the underscore; a bare PASSPHRASE matched nothing, because PASS is followed by P. No common non-secret variable is named *PASSPHRASE, so there is no false positive traded away. |
APIKEY | no entry needed | KEY at the end of a name already matches it. |
PRIVKEY | no entry needed | Same. |
SECRETKEY | no entry needed | Same. |
PWD | rejected | The POSIX current-working-directory variable, present in every shell, with a value that is almost always over the length floor. Detecting it would replace the working directory with a placeholder in every message mentioning a path: text corruption, not protection. OLDPWD is the same. |
Three of the five filed candidates turned out to be already covered, which is why the list stays short: the trailing-position half of the boundary rule does most of the work.
User files ADD ONLY. A project file that could remove TOKEN would let a cloned repository turn off protection for whoever opens it, which is the wrong direction for a detection list to be configurable in. A missing file is empty; an unreadable or malformed one throws, the same asymmetry secrets.yml uses. buildEnvSecretPattern([]) matches NOTHING rather than emitting an empty alternation that would match every name, and every keyword is regex-escaped because a user file is arbitrary text.
secrets.yml
Define custom secret entries in YAML. Two locations are checked:
| Level | Path | Purpose |
|---|---|---|
| Profile | ~/.veyyon/profiles/default/agent/secrets.yml (active agent dir) | Profile-wide secrets |
| Project | <cwd>/.veyyon/secrets.yml | Project-specific secrets |
Project entries override profile entries with matching content. The profile level is called profile here and everywhere else the agent directory appears, including the vault’s scope table above. It was labelled “Global” in this table alone, which read as ~/.veyyon and is a different directory.
Schema
Each entry in the array has these fields:
| Field | Type | Required | Description |
|---|---|---|---|
type | "plain" or "regex" | Yes | Match strategy |
content | string | Yes | The secret value (plain) or regex pattern (regex) |
mode | "obfuscate" or "replace" | No | Default: "obfuscate" |
replacement | string | No | Custom replacement (replace mode only) |
flags | string | No | Regex flags (regex type only) |
minLength | positive integer | No | Shortest match this pattern will obfuscate. Regex entries only; default 8 |
Examples
Plain secrets
# Obfuscate a specific API key (default mode)
- type: plain
content: sk-proj-abc123def456
# Replace a database password with a fixed string
- type: plain
content: hunter2
mode: replace
replacement: "********"
Generated replace aliases use counter-mode HMAC with the machine placeholder key. A custom replacement that looks like #NAME# or a machine-keyed placeholder is rejected, so one-way output cannot be reinterpreted as a live credential. Emitted placeholders are protected spans: later literal or regex rules cannot scan inside and corrupt them.
Regex secrets
# Obfuscate any AWS-style key
- type: regex
content: "AKIA[0-9A-Z]{16}"
# Case-insensitive match with explicit flags
- type: regex
content: "api[_-]?key\\s*=\\s*\\w+"
flags: "i"
# Regex literal syntax (pattern and flags in one string)
- type: regex
content: "/bearer\\s+[a-zA-Z0-9._~+\\/=-]+/i"
# A six-digit one-time code is shorter than the default floor, so say so
- type: regex
content: "\\b[0-9]{6}\\b"
minLength: 6
Regex entries always scan globally (the g flag is enforced automatically). The regex literal syntax /pattern/flags is supported as an alternative to separate content + flags fields. Escaped slashes within the pattern (\\/) are handled correctly.
Alternations whose branches can consume concatenated prefixes are rejected along with nested ambiguous quantifiers. This prevents exponential backtracking even when the ambiguity is spread across alternatives.
Only standard, bounded global matching is accepted. The sticky y flag and expressions that can match an empty string are rejected because their scan semantics can skip text or make no progress. Nested ambiguous quantifiers and related catastrophic-backtracking forms are rejected before compilation. Regex replacement rewrites exact match spans rather than every equal substring elsewhere in the message.
Replace mode with regex
# One-way replace connection strings (not reversible)
- type: regex
content: "postgres://[^\\s]+"
mode: replace
replacement: "postgres://***"
Interaction with env var detection
Environment variables are collected first, then file-defined entries are appended. File entries can cover secrets that do not live in environment variables, such as values in local configuration. Equal plain values converge on the same machine-keyed placeholder, so their provider representation is independent of declaration order.
Key files
packages/coding-agent/src/secrets/audit.ts– the expansion log: record shape, atomic-append cap, readerpackages/coding-agent/src/secrets/env-keywords.ts+env-keywords.yml– the Tier B keyword list and the boundary rule, one ownerpackages/coding-agent/src/secrets/index.ts– loading, merging, env var collection, refusal of unprotectable entriespackages/coding-agent/src/secrets/obfuscator.ts–SecretObfuscator, message obfuscation, runtime add/forgetpackages/coding-agent/src/secrets/placeholder.ts– both placeholder forms and the rule keeping them apartpackages/coding-agent/src/secrets/policy.ts– the length rules and the rejection type, defined oncepackages/coding-agent/src/secrets/regex.ts– regex literal parsing and compilationpackages/coding-agent/src/secrets/secret-command.ts–/secretlogic, pure and session-freepackages/coding-agent/src/secrets/scope-move.ts–planScopeMove: the two refusals that make a scope move safe to perform as add-then-removepackages/coding-agent/src/secrets/vault.ts– entries, lifetimes, scopes, the storepackages/coding-agent/src/secrets/vault-crypto.ts– the key, the seal, and the threat modelpackages/coding-agent/src/slash-commands/helpers/secret.ts– the session-bound adapter shared by the TUI and text/ACP pathspackages/coding-agent/src/system-prompt-builder/section-registry.ts– the runtime section row that puts the inventory of spendable names in the base system promptpackages/coding-agent/src/session/operator-notices.ts– the one channel for a warning that must reach a personpackages/tui/src/components/input.ts–Input.maskandmaskValue, the one place a value becomes visible textpackages/coding-agent/src/config/settings-domains/providers.ts– the three settings:secrets.enabled,secrets.defaultTtl,secrets.auditLog
See also
auth-broker-gateway.md– remote credential vault and forward-proxy that keep provider OAuth refresh tokens and access tokens off developer hosts entirely (complementary to in-process obfuscation).
Autonomous Memory
When a memory backend is enabled, the agent automatically extracts durable knowledge from past sessions and injects a compact summary into future sessions for the same project. Over time it builds a project-scoped memory store, technical decisions, recurring workflows, pitfalls, that carries forward without manual effort.
Backends
memory.backend selects the subsystem (default off):
| Value | What it is |
|---|---|
off | No memory subsystem runs. |
local | Local rollout-summarisation pipeline described on this page (MEMORY.md / memory_summary.md / generated skills). |
mnemopi | Local SQLite recall/retain backend with optional embeddings; the agent uses the recall, retain, and reflect tools. mnemopi.* settings tune it. |
hindsight | Vectorize Hindsight remote memory service. |
The rest of this page documents the local pipeline. Enable it via /settings or config.yml:
memory:
backend: local
Usage
What gets injected
At session start, if a memory summary exists for the current project, it is injected into the system prompt as a Memory Guidance block. The agent is instructed to:
- Treat memory as heuristic context: useful for process and prior decisions, not authoritative on current repo state.
- Cite the memory artifact path when memory changes the plan, and pair it with current-repo evidence before acting.
- Prefer repo state and user instruction when they conflict with memory; treat conflicting memory as stale.
A backend contributes in two places, and which one it uses matters for what a session costs you:
- The system prompt contains the guidance that does not change while the session runs. The provider caches the prompt as the prefix of every request, so this text is paid for once.
- The context tail carries whatever changes as you work: memories recalled for the current question, and the mental-model block when it reloads. These arrive as a message alongside your prompt.
The split exists because changing the system prompt mid-session invalidates the provider’s cache prefix, and the next request re-reads the whole conversation at the uncached rate. Writing a recalled memory into the prompt made every recall cost a full re-read of everything before it. The model sees the same text in the same order either way.
A block is sent once. If a reload finds the same memories, nothing is sent, so the context does not grow by a copy of your memories every turn.
/memory view shows both halves together, so what you read there is what the model gets.
Reading memory artifacts
The agent can read memory files directly using memory:// URLs with the read tool:
| URL | Content |
|---|---|
memory://root | Compact summary injected at startup |
memory://root/MEMORY.md | Full long-term memory document |
memory://root/skills/<name>/SKILL.md | A generated skill playbook |
/memory slash command
| Subcommand | Effect |
|---|---|
view | Show the current backend injection payload |
stats | Show backend-specific memory statistics, when supported |
diagnose | Show backend-specific diagnostics, when supported |
clear / reset | Delete active backend memory data/artifacts |
enqueue / rebuild | Force consolidation/retention work for the active backend |
mm list | List mental models on the active bank |
mm show <id> | Show one mental model |
mm refresh [id] | Refresh auto-refresh models bank-wide, or one model by id |
mm history <id> | Diff the change history of a mental model |
mm seed | Create any built-in mental models that are missing |
mm delete <id> | Delete a mental model from the bank |
mm reload | Re-pull the cached <mental_models> block |
How it works
Local summary memories are built by a background pipeline that runs at startup; /memory enqueue marks consolidation work that the next startup picks up. The pipeline is skipped for subagents and for sessions that are not persisted to a session file.
Phase 1, per-session extraction: For each past session that has changed since it was last processed, a model reads the session history and extracts durable signal: technical decisions, constraints, resolved failures, recurring workflows. Sessions that are too recent, too old, currently active, or beyond the configured scan/age limits are skipped. Each extraction produces a raw memory block and a short synopsis for that session.
Phase 2, consolidation: After extraction, a second model pass reads all per-session extractions and produces three outputs written to disk:
MEMORY.md: a curated long-term memory documentmemory_summary.md: the compact text injected at session startskills/: reusable procedural playbooks, each in its own subdirectory
Phase 2 uses a lease and heartbeat to prevent double-running when multiple processes start simultaneously. Stale skill directories from prior runs are pruned automatically.
Consolidated output is redacted for common secret/token patterns before MEMORY.md, memory_summary.md, or generated skills are written to disk.
Extraction behavior
Memory extraction and consolidation behavior is driven by static prompt files in packages/coding-agent/src/prompts/memories/.
| File | Purpose | Variables |
|---|---|---|
stage_one_system.md | System prompt for per-session extraction | n/a |
stage_one_input.md | User-turn template wrapping session content | {{thread_id}}, {{response_items_json}} |
consolidation_system.md | System prompt for cross-session consolidation | n/a |
consolidation.md | User-turn prompt for cross-session consolidation | {{raw_memories}}, {{rollout_summaries}} |
read-path.md | Memory guidance injected into live sessions | {{memory_summary}}, {{learned}} |
Model selection
Memory piggybacks on the model role system.
| Phase | Role | Purpose |
|---|---|---|
| Phase 1 (extraction) | default | Per-session knowledge extraction |
| Phase 2 (consolidation) | smol (falls back to default, then current/first registry model) | Cross-session synthesis |
If the requested memory role is not configured, memory model resolution falls back to the default role, then the active session model, then the first model in the registry.
Configuration
| Setting | Default | Description |
|---|---|---|
memory.backend | off | Select local for this pipeline; legacy memories.enabled: true is migrated to memory.backend: local when no explicit backend is set |
memories.maxRolloutAgeDays | 30 | Sessions older than this are not processed |
memories.minRolloutIdleHours | 12 | Sessions active more recently than this are skipped |
memories.maxRolloutsPerStartup | 64 | Cap on sessions processed in a single startup |
memories.summaryInjectionTokenLimit | 5000 | Max tokens of the summary injected into the system prompt |
Additional tuning knobs (concurrency, lease durations, token budgets) are available in config for advanced use.
Key files
packages/coding-agent/src/memories/index.ts: pipeline orchestration, injection, clear/enqueue entry points (the/memorycommand routes here viapackages/coding-agent/src/memory-backend/local-backend.ts)packages/coding-agent/src/memories/storage.ts: SQLite-backed job queue and thread registrypackages/coding-agent/src/prompts/memories/: memory prompt templatespackages/coding-agent/src/internal-urls/memory-protocol.ts:memory://URL handler
Compaction and Branch Summaries
Compaction and branch summaries are the two mechanisms that keep long sessions usable without losing prior work context.
- Compaction rewrites old history into a summary on the current branch.
- Branch summary captures abandoned branch context during
/treenavigation.
Both are persisted as session entries and converted into agent-attributed developer context when rebuilding LLM input.
Key implementation files
packages/agent/src/compaction/compaction.ts(context-full summarization and handoff generation)packages/agent/src/compaction/legacy-snapcompact-archive.ts(reads archives left by the removed image-archive engine so old sessions keep loading)packages/agent/src/compaction/branch-summarization.tspackages/agent/src/compaction/pruning.tspackages/agent/src/compaction/utils.tspackages/coding-agent/src/session/session-manager.tspackages/coding-agent/src/session/agent-session.tspackages/coding-agent/src/session/messages.tspackages/coding-agent/src/extensibility/hooks/types.tspackages/coding-agent/src/config/settings-schema.ts
Session entry model
Compaction and branch summaries are first-class session entries, not plain assistant/user messages.
CompactionEntrytype: "compaction"summary, optionalshortSummary(display only, and no longer produced bycompact(): see “Short summary” below)firstKeptEntryId(compaction boundary)tokensBefore- optional
details,preserveData,fromExtension
BranchSummaryEntrytype: "branch_summary"fromId,summary- optional
details,fromExtension
When context is rebuilt (buildSessionContext):
- Latest compaction on the active path is converted to one
compactionSummarymessage. - Kept entries from
firstKeptEntryIdto the compaction point are re-included. - Later entries on the path are appended.
branch_summaryentries are converted tobranchSummarymessages.custom_messageentries are converted tocustommessages.
convertToLlm() transforms these custom roles into LLM-facing messages, through these static
templates:
packages/agent/src/prompts/compaction/compaction-summary-context.mdpackages/agent/src/prompts/compaction/branch-summary-context.md
branchSummary becomes an agent-attributed developer message.
compactionSummary becomes an agent-attributed user message. The role is the trust boundary: a
compaction summary is model-generated history, so putting it in the user channel means it cannot
outrank a live developer message that contradicts it. Any image attachments follow the summary text
in the same message, which is also why the user slot is the safe one: every provider accepts images
there.
The compaction template wraps the summary in its own <summary> delimiters, so the untrusted region
has an explicit start and end. Exactly one wrapper is ever emitted: a legacy or model-authored
<summary …> wrapper persisted inside the summary text is stripped first
(withoutSummaryPresentationTags), and embedded or sibling <summary> elements that are not one
enclosing wrapper are left alone as content. The branch template uses no delimiters.
Other custom messages pass through as developer messages with their raw content and no template.
Compaction pipeline
Triggers
Compaction/context maintenance can run in six ways:
- Manual context compaction:
/compact [summary] [focus]callsAgentSession.compact(...). - Automatic overflow recovery: after a same-model assistant error that matches context overflow.
- Automatic incomplete-output recovery: after a same-model assistant message ends with
stopReason === "length"(OpenAI/Codexresponse.incomplete). - Automatic threshold maintenance: after a successful turn when context exceeds the resolved threshold.
- Mid-turn threshold maintenance: before the next provider request when a tool-loop turn crosses the threshold and
compaction.midTurnEnabled !== false. - Idle maintenance:
runIdleCompaction()can invoke the same auto-maintenance path with reason"idle".
Compaction shape (visual)
Before compaction:
entry: 0 1 2 3 4 5 6 7 8 9
┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘
└────────┬───────┘ └──────────────┬──────────────┘
messagesToSummarize kept messages
↑
firstKeptEntryId (entry 4)
After compaction (new entry appended):
entry: 0 1 2 3 4 5 6 7 8 9 10
┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
└──────────┬──────┘ └──────────────────────┬───────────────────┘
not sent to LLM sent to LLM
↑
starts from firstKeptEntryId
What the LLM sees:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └─────────────────┬────────────────┘
prompt from cmp messages from firstKeptEntryId
Overflow/incomplete recovery vs threshold/idle maintenance
The automatic paths are intentionally different:
-
Overflow recovery
- Trigger: current-model assistant error is detected as context overflow and the error is not older than the latest compaction.
- The failing assistant error message is removed from active agent state before retry.
- Context promotion is tried first; if a configured larger model is available, the agent switches model and retries without compacting.
- If promotion is unavailable and compaction is enabled, in-place compaction runs with
reason: "overflow"andwillRetry: true. - On success,
agent.continue()is scheduled to retry the turn.
-
Incomplete-output recovery
- Trigger: same-model assistant message ends with
stopReason === "length"and the message is not older than the latest compaction. - The incomplete assistant message is removed from active agent state before recovery.
- Context promotion is tried first.
- If promotion is unavailable and compaction is enabled, auto maintenance runs with
reason: "incomplete"andwillRetry: true. - On context-full success,
agent.continue()is scheduled to retry the turn.
- Trigger: same-model assistant message ends with
-
Threshold maintenance
- Trigger: successful, non-error assistant message whose adjusted context tokens exceed
resolveThresholdTokens(...). - Mid-turn maintenance also checks safe tool-loop boundaries before the next provider request when
compaction.midTurnEnabled !== false. - Tool-output pruning can reduce the measured token count before threshold comparison.
- Context promotion is tried before post-turn compaction.
- If promotion is unavailable, auto maintenance runs with
reason: "threshold"andwillRetry: false. - On success, if
compaction.autoContinue !== false, post-turn maintenance schedules an agent-authored developer auto-continue prompt fromprompts/turn-control/auto-continue.md; mid-turn maintenance never schedules a separate continuation because the core loop already defines the next provider request.
- Trigger: successful, non-error assistant message whose adjusted context tokens exceed
-
Idle maintenance
- Trigger:
runIdleCompaction()when not streaming or already compacting. - Uses
reason: "idle"and does not auto-continue afterward.
- Trigger:
Compaction and manual handoff
summary is the sole compaction strategy, and it continues the SAME session. The generated summary
is prefixed onto a retained raw tail in one message array: buildSessionContext pushes the summary,
then re-emits every entry from firstKeptEntryId onward. findCutPoint walks backwards
accumulating until keepRecentTokens (default 10000), and because it can only cut at a turn
boundary, prepareCompaction then hard-bounds the tail: a kept turn whose bulk exceeds the budget
has its heavy non-error tool results replaced with an elision marker (largest first, originals
offloaded to a recovery artifact:// blob), so the tail stays within budget even when one turn
alone is bigger. User messages, assistant text, tool calls, and error results are never elided.
Note that the summary prompt does not state any of that. Its opening line requests “a structured handoff summary for another LLM to resume the task”, which describes a cold restart that compaction does not perform. This is inherited from upstream, whose engine keeps the same recent tail, so the mismatch is upstream’s rather than a fork difference. It is recorded here because a summarizer told it is writing for a fresh reader will restate turns that are still in context. Changing the prompt is an operator decision, not a fix to apply locally.
/handoff is a separate, explicit operation that starts a new session. Nothing carries over except
its generated transfer document. Automatic compaction never selects or schedules a handoff.
Legacy compaction strategies
Earlier versions offered snap, handoff, and other strategy values. They now
migrate to summary. Legacy off also sets compaction.enabled: false.
Sessions compacted by the old engine still open without loss. The removed engine always stored the full plaintext source alongside its image frames, so a legacy archive degrades gracefully:
- On each context rebuild,
legacyArchiveSourceText(inpackages/agent/src/compaction/legacy-snapcompact-archive.ts) reads the archived source fromCompactionEntry.preserveData.snapcompactand re-attaches it as a single recovered text block on the compaction summary. The old image frames are never rehydrated, which also removes the oversized-payload hazard they carried. - The next compaction over such a session drains that recovered source into the fresh LLM summary and drops the legacy archive from
preserveData, so the session converges to a plain summarized history.
Display transcript
By default the live TUI collapses pre-compaction history: display.collapseCompacted defaults to true, so only the latest compacted tail renders live above the summary divider and the scrollback is cleared at the compaction point. Set display.collapseCompacted to false to keep the full display transcript inline instead (buildSessionContext({ transcript: true }) / AgentSession.buildTranscriptSessionContext()): every path entry in chronological order, with each compaction shown as a slim divider, ── 📷 compacted · ctrl+o ──, at the point it fired. Expanding (ctrl+o) reveals the summary. In the collapsed default the LLM context and the visible transcript reset together; in the inline mode only the LLM context resets, and the scrollback above the divider stays intact, including across session resume.
Per-turn and pre-compaction pruning
Two passes run from AgentSession.#checkCompaction(), after every completed turn, and both persist
through rewriteEntries() so the session file matches the live context (/fork, /tan and resume
read the file, and a divergent prefix cold-misses the provider prompt cache):
- Stale-result pass (
#pruneStaleToolResults→pruneSupersededToolResults) runs first, before any threshold gating, so it fires even withcompaction.enabledoff. It is skipped entirely when bothcompaction.supersedeReadsandcompaction.dropUselessare false. - Threshold prune (
#pruneToolOutputs→pruneToolOutputs) runs only on the threshold path, after thecompaction.enabled/ strategy check and after error turns are skipped, and only once the turn has usable usage data. Its savings feedpostMaintenanceContextTokens, which is the trigger figure reported to the compaction it may schedule.
Default prune policy:
- Protect newest
40_000tool-output tokens. - Require at least
20_000total estimated savings. - Never blank a result below
50tokens (MIN_PRUNE_TOKENS): the[Output truncated - N tokens]placeholder costs ~8 tokens, so pruning a sub-floor result would grow the context and churn the prompt cache for nothing. (Superseded and useless results keep their own rules: the useless collector already drops no-savings candidates; superseded reads prune for correctness regardless of size.) - Never prune
skilltool results,readresults ofskill://paths, or reads of the active plan reference file (added viaAgentSession’s plan protection).
Pruned tool results are replaced with:
[Output truncated - N tokens]
Superseded-read elision
Gated by compaction.supersedeReads (default on). When it is on, the stale-result pass keys every
read result by readToolSupersedeKey (path plus selector grammar; a selector-free read supersedes
range reads of the same base path, URL-scheme paths are exempt), and every result but the newest in
a key group is blanked to the exact placeholder [Superseded by a newer read of this file]
(SUPERSEDED_NOTICE). Turning the setting off passes no key function, so no read is ever grouped and
every read result survives at full length.
Blanking happens only where it is cheap: when the messages after the candidate total at most ~8k
estimated tokens (PRUNE_CACHE_WARM_SUFFIX_TOKENS, the read→edit→read tail), or when the last
message is at least 90 minutes old (PRUNE_IDLE_FLUSH_MS, past the 1h Anthropic “long” prompt-cache
retention), in which case every still-sent candidate flushes at once. Entries before the latest
compaction’s firstKeptEntryId are summarized away and are never rewritten.
Useless-result elision
Tools can flag a finished result as contextually useless, a search with zero matches, a job poll that timed out with everything still running, an empty irc inbox drain. The flag originates on the tool result (AgentToolResult.useless, set via ToolResultBuilder.useless() or directly on the returned object), is copied by the agent loop onto the persisted ToolResultMessage (never together with isError, errors always win), and is consumed in three places:
- Per-turn stale-result pass (
pruneSupersededToolResults, gated bycompaction.dropUseless, default on): flagged results are blanked to the exact placeholder[Uneventful result elided](USELESS_NOTICE) with the same cache-aware timing as superseded reads: only when the suffix after the candidate is small (≤ ~8k tokens) or the session has idled past the provider prompt-cache lifetime. Results smaller than the notice itself are never blanked (no savings), and protected tools are exempt. - Threshold prune (
pruneToolOutputs): flagged results bypass the protect-recent window, same as superseded reads, and receiveUSELESS_NOTICEinstead of the token-count placeholder. - Summary serialization:
serializeConversationdrops the whole tool call/result pair from summarizer input: the source region is discarded after summarization anyway, so the exclusion costs no cache.
The flag never reaches provider wire formats, and flagged pairs are never removed from history (only blanked in place), so tool-call/result pairing stays intact.
What the summary prompts request
compaction-summary.md, compaction-update-summary.md, and compaction-summary-context.md are
oh-my-pi’s text verbatim, by operator order, on the measurement that upstream scores higher on
long-run evals. packages/agent/test/compaction-strategy-contracts.test.ts pins each one by
SHA-256 and preflight runs it, so an unapproved edit fails the build instead of quietly changing
summary quality. Approving a change means updating the digest in the same commit.
Both prompts request the same ten sections, in the same order: ## Goal,
## Constraints & Preferences, ## Progress (### Done, ### In Progress, ### Blocked),
## Key Decisions, ## Next Steps, ## Critical Context, ## Additional Notes. Sections may be
omitted when they do not apply. The lists have to match, because iterative compaction feeds its own
output back in: a section the update prompt failed to name would be dropped on every cycle.
Both require exact file paths, function names, and error messages preserved rather than paraphrased,
require repository state changes (branch, uncommitted changes) when mentioned, forbid any text
outside the structured summary, and require an unanswered question to the user to survive. The
initial prompt preserves that question verbatim; the update prompt files it into ## Critical Context, replacing a previous pending question once it has been answered.
## Goal is a single undifferentiated field: the prompts do not separate a durable overarching goal
from the current task, and only handoff-document.md still draws that line. The update prompt also
instructs the model to preserve all information from the previous summary and permits removing only
what is no longer relevant, so iterative compaction accumulates rather than replacing drift.
Empty responses
Neither a compaction summary nor an explicit handoff document may be empty. A provider can finish with stopReason: "stop" after spending its output budget on reasoning and emit no text. Both call sites raise instead of persisting an empty artifact. Lower the compaction thinking level if this repeats so the model spends its budget on the document.
Boundary and cut-point logic
prepareCompaction() only considers entries since the last compaction entry (if any).
- Find previous compaction index.
- Compute
boundaryStart = prevCompactionIndex + 1. - Adapt
keepRecentTokensusing measured usage ratio when available. - Run
findCutPoint()over the boundary window.
Valid cut points include:
- message entries with roles:
user,assistant,bashExecution,hookMessage,branchSummary,compactionSummary custom_messageentriesbranch_summaryentries
Hard rule: never cut at toolResult.
If there are non-message metadata entries immediately before the cut point (model_change, thinking_level_change, labels, etc.), they are pulled into the kept region by moving cut index backward until a message or compaction boundary is hit.
Split-turn handling
If cut point is not at a user-turn start, compaction treats it as a split turn.
Turn start detection treats these as user-turn boundaries:
message.role === "user"message.role === "bashExecution"custom_messageentrybranch_summaryentry
Split-turn compaction generates two summaries:
- History summary (
messagesToSummarize) - Turn-prefix summary (
turnPrefixMessages)
Final stored summary is merged as:
<history summary>
---
**Turn Context (split turn):**
<turn prefix summary>
Summary generation
compact(...) builds summaries from serialized conversation text:
- Convert messages via
convertToLlm(). - Serialize with
serializeConversation(). - Wrap in
<conversation>...</conversation>. - Optionally include
<previous-summary>...</previous-summary>. - Optionally inject extension hook context and active memory-backend compaction context as
<additional-context>entries. - Execute summarization prompt with
SUMMARIZATION_SYSTEM_PROMPT.
Prompt selection:
- first compaction:
compaction-summary.md - iterative compaction with prior summary:
compaction-update-summary.md - split-turn second pass:
compaction-turn-prefix.md - handoff document:
handoff-document.md(used only by explicitgenerateHandoff(...), not serialized compaction)
Short summary
CompactionEntry.shortSummary is a display-only, pull-request-style line. compact() no longer
generates one: a second model request per compaction, spent on text the model never reads, is not
worth the input cost. Every reader stays, because compaction hooks still set the field and sessions
written before the change still carry it.
Its one display consumer is the session-listing title fallback (title: header.title ?? shortSummary
in packages/coding-agent/src/session/session-listing.ts), which veyyon reaches only when its own
tiny-model titler declined: VEYYON_NO_TITLE set, or a first message too low-signal to title from.
In that case the session picker falls back again to the first user message, so nothing renders blank.
Remote summarizer endpoint:
- When
compaction.remoteEndpointis set, summary generation POSTs one of two wire formats:- custom veyyon summarizer endpoints receive
{ systemPrompt, prompt }and must return JSON containing at least{ summary }. - OpenAI-compatible endpoints whose path ends in
/chat/completionsreceive{ model, messages, stream: false }, wheremessagescontains one system prompt and one user prompt. The summary is read fromchoices[0].message.content, which lets self-hosted servers such as llama.cpp and vLLM act as summarizers without a separate shim.
- custom veyyon summarizer endpoints receive
- When it is unset, the active model generates the summary locally. That is the default for every provider.
Server-side compaction (compaction.remote, on by default):
OpenAI and Azure OpenAI serve POST /responses/compact, which compacts a session’s
context inside the provider and returns the compacted window. Veyyon uses it when the
model’s compat.supportsServerCompaction flag is set, which is resolved per host at
model build time: the official OpenAI API and Azure’s v1 API today, and any gateway
that opts in with an override. The Codex provider stays out, because its transport
owns history state server-side and a client-minted window has no replay contract there.
A re-pointed openai model also stays out, since another vendor’s host does not serve
that path. Turning compaction.remote off is the only thing that disables it; leaving
it unset leaves it on.
A server-side compaction stores no summary text, and that is deliberate. The window
it returns is an encrypted_content blob minted under the provider’s key. There is
nothing in it to read, and nothing to decrypt: it is the compacted context itself, meant
to be handed straight back to the same provider. The path used to run a full local
summarization of the same span alongside the remote call and store both, which cost the
remote call plus the exact summary the remote call was supposed to replace, and only one
of the two was ever read. Writing readable text here is not a missing feature that could
be added later. The only way to produce it is to pay a second model to describe a span,
which is the local strategy with an extra network round trip in front of it, and any text
derived from the blob rather than the span would be invented. An empty summary is the
honest record of what happened.
Because the entry cannot explain itself, the rebuild will not trust it outside the
provider that minted it. buildSessionContext treats a compaction as usable only when the
stored window replays on the active provider, or when there is real summary text. When
neither holds, which is a fork or resume onto a different provider, it re-expands every
message the compaction hid. Nothing was lost to recover: compaction only advances
firstKeptEntryId, so the discarded span is still in the session file.
Sessions compacted by the earlier, removed path (preserveData.openaiRemoteCompaction,
whose summary field held a fixed placeholder) load through the same rule and re-expand.
Handoff generation
packages/agent/src/compaction/compaction.ts also exports generateHandoff(...). Handoff generation uses the same completeSimple(...) oneshot style as summarization, but it preserves the live agent cache prefix by sending the active system prompt, tool array, and real LLM message history, then appending one agent-attributed user message containing the handoff prompt. It forces toolChoice: "none" and returns joined text blocks directly.
Handoff does not write a CompactionEntry. AgentSession.handoff() performs the session transition: it starts a new session, injects the generated document as a visible custom_message with customType: "handoff", and rebuilds agent messages from that new session.
File-operation context in summaries
Compaction tracks cumulative file activity using assistant tool calls:
read(path)→ read setwrite(path)→ modified setedit(path)→ modified set
Cumulative behavior:
- Includes prior compaction details only when prior entry is pi-generated (
fromExtension !== true). - In split turns, includes turn-prefix file ops too.
details.readFilesexcludes files also modified;details.modifiedFilescontains the rest (persisted shape is unchanged).
The file list is a grouped, prefix-folded directory tree (find-tool shape) with a per-file access marker, (Read) for read-only files, (Write) for modified files never read, (RW) for modified files also present in the cumulative read set. Capped at 20 files with an […N files elided…] line. Compaction and explicit handoff append it as a <files> tag (via upsertFileOperations).
<files>
# packages/agent/src/compaction/
compaction.ts (Read)
utils.ts (RW)
## prompts/
file-operations.md (Write)
</files>
Legacy <read-files>/<modified-files> tags from summaries written by earlier versions are stripped (alongside <files>) before re-appending, so old summaries self-heal on the next compaction.
Persist and reload
After summary generation (or a hook-provided summary), agent session:
- Appends a
CompactionEntrywithappendCompaction(...). - Rebuilds display context from the active leaf via
buildDisplaySessionContext(). - Replaces live agent messages with rebuilt context.
- Synchronizes active todo phases from the rebuilt branch and closes provider sessions whose history was rewritten.
- Emits
session_compacthook event.
Branch summarization pipeline
Branch summarization is tied to tree navigation, not token overflow.
Trigger
During navigateTree(...):
- Compute abandoned entries from old leaf to common ancestor using
collectEntriesForBranchSummary(...). - If caller requested summary (
options.summarize), generate summary before switching leaf. - If summary exists, attach it at the navigation target using
branchWithSummary(...).
Operationally this is commonly driven by /tree flow when branchSummary.enabled is enabled.
Branch switch shape (visual)
Tree before navigation:
┌─ B ─ C ─ D (old leaf, being abandoned)
A ───┤
└─ E ─ F (target)
Common ancestor: A
Entries to summarize: B, C, D
After navigation with summary:
┌─ B ─ C ─ D ─ [summary of B,C,D]
A ───┤
└─ E ─ F (new leaf)
Preparation and token budget
generateBranchSummary(...) computes budget as:
tokenBudget = model.contextWindow - branchSummary.reserveTokens
prepareBranchEntries(...) then:
- First pass: collect cumulative file ops from all summarized entries, including prior pi-generated
branch_summarydetails. - Second pass: walk newest → oldest, adding messages until token budget is reached.
- Prefer preserving recent context.
- May still include large summary entries near budget edge for continuity.
Compaction entries are included as messages (compactionSummary) during branch summarization input.
Summary generation and persistence
Branch summarization:
- Converts and serializes selected messages.
- Wraps in
<conversation>. - Uses custom instructions if supplied, otherwise
branch-summary.md. - Calls summarization model with
SUMMARIZATION_SYSTEM_PROMPT. - Prepends
branch-summary-preamble.md. - Appends file-operation tags.
Result is stored as BranchSummaryEntry with optional details (readFiles, modifiedFiles).
Extension and hook touchpoints
session_before_compact
Pre-compaction hook.
Can:
- cancel compaction (
{ cancel: true }) - provide full custom compaction payload (
{ compaction: CompactionResult })
session_compacting
Prompt/context customization hook for default compaction.
Can return:
prompt(override base summary prompt)context(extra context lines injected into<additional-context>)preserveData(stored on compaction entry)
session_compact
Post-compaction notification with saved compactionEntry and fromExtension flag.
session_before_tree
Runs on tree navigation before default branch summary generation.
Can:
- cancel navigation
- provide custom
{ summary: { summary, details } }used when user requested summarization
session_tree
Post-navigation event exposing new/old leaf and optional summary entry.
Which model compacts
compaction.model (settings/config.yml, --compaction-model CLI, or the Compaction Model picker in /settings) selects the model used for LLM compaction and handoff generation. Default: unset, compaction inherits the main session model live, so switching the session model also switches the compactor. When set, resolveCompactionModelPatterns expands the value through the normal pattern/role resolution (role aliases like "@smol" and :thinking suffixes work), and auto compaction tries the resulting candidates in order. The value is a chain and can be written either way, as a comma-separated string (opus,sonnet) or as a YAML list; both normalize to the same ordered candidates. Legacy config keys compaction.compactionModel / top-level compactionModel are migrated to compaction.model on load.
Runtime behavior and failure semantics
- Manual compaction aborts current agent operation first.
abortCompaction()cancels manual compaction, auto-compaction, and handoff generation controllers.- Auto compaction emits start/end session events for UI/state updates.
- Auto compaction can try multiple model candidates and retry transient failures; long retry delays prefer the next candidate when one is available.
- Overflow errors are excluded from generic retry path because they are handled by context promotion/compaction.
- If auto-compaction fails:
- overflow path emits
Context overflow recovery failed: ... - incomplete-output path emits
Incomplete response recovery failed: ... - threshold/idle paths emit
Auto-compaction failed: ...
- overflow path emits
- Branch summarization can be cancelled via abort signal (e.g., Escape), returning canceled/aborted navigation result.
Settings and defaults
From settings-schema.ts:
compaction.enabled=truecompaction.strategy="summary", the sole strategy. Every stored legacy strategy token migrates tosummary; legacyoffalso setscompaction.enabled: false. Use/handofffor an explicit transfer to a new session.compaction.reserveTokens= unset (absent key). When unset the compaction layer falls back toDEFAULT_RESERVE_TOKENS=16384, and small-window recovery may substitute a proportional 15%-of-window reserve when the default does not fit the window (resolveBudgetReserveTokens).compaction.keepRecentTokens=10000compaction.supersedeReads=true(drop earlier file reads that a later read of the same file makes redundant)compaction.dropUseless=truecompaction.handoffSaveToDisk=false(also write the handoff packet to disk)compaction.modelContextWindow= unset (absent key); overrides the window size the compaction budget resolves againstcompaction.autoContinue=truecompaction.midTurnEnabled=truecompaction.remoteEndpoint=undefinedcompaction.threshold=auto; the one trigger setting, with its unit in the value.autoiscontextWindow - max(15% of contextWindow, reserveTokens).85%is a percent of the current model’s window.170000is an absolute token amount, model-independent: compaction runs once context exceeds that many tokens whatever the current model’s window is, and when the amount is larger than that window it is honored up tocontextWindow - 1with a one-time warning (never silently reinterpreted). Resolution and the migration off the two retired keys live inpackages/agent/src/compaction/threshold.ts.compaction.thresholdTokens=-1andcompaction.thresholdPercent=-1; retired. The global config is rewritten on load (#migrateRawSettings): a positive amount becomesthreshold: <amount>, a positive percent becomesthreshold: <percent>%(the amount wins when both are set), and both keys are dropped, so the ambiguity leaves the file without moving the trigger. Config sources that are never rewritten — project files,--configoverlays — are folded in at read time bywithLegacyCompactionThresholdwith the same precedence, and the session reports which retired key supplied the value.compaction.idleEnabled=falsecompaction.idleThresholdTokens=200000compaction.idleTimeoutSeconds=300branchSummary.enabled=falsebranchSummary.reserveTokens=16384
These values are consumed at runtime by AgentSession and compaction/branch summarization modules.
TUI integration for extensions and custom tools
The current TUI contract used by packages/coding-agent and packages/tui for extension UI, custom tool UI, and custom renderers.
What this subsystem is
The runtime has two layers:
- Rendering engine (
packages/tui): differential terminal renderer, input dispatch, focus, overlays, cursor placement. - Integration layer (
packages/coding-agent): mounts extension/custom-tool components, wires keybindings/theme, and restores editor state.
Runtime behavior by mode
| Mode | ctx.ui.custom(...) availability | Notes |
|---|---|---|
| Interactive TUI | Supported | Component is mounted in the editor area or overlay, focused, and must call done(result) to resolve. |
| Background/headless | Not interactive | UI context is no-op (hasUI === false). |
| RPC mode | Not mounted | custom() is implemented as unsupported UI and returns undefined as never; do not depend on interactive UI in RPC handlers. |
If your extension/tool can run in non-interactive mode, guard with ctx.hasUI / pi.hasUI.
Core component contract (@veyyon/tui)
packages/tui/src/tui.ts defines:
export interface Component {
render(width: number): readonly string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate?(): void;
dispose?(): void;
}
Render results are component-owned and immutable to callers; a component that did not change should return the same array reference it returned last time (reference equality is what enables the renderer’s memoization and row virtualization), and must return a new array whenever its content changed.
Focusable is separate:
export interface Focusable {
focused: boolean;
setUseTerminalCursor?(useTerminalCursor: boolean): void;
}
Cursor behavior uses CURSOR_MARKER (not getCursorPosition). Focused components emit the marker in rendered text; TUI extracts it and positions the hardware cursor.
Rendering constraints (terminal safety)
Your render(width) output must be terminal-safe:
- Do not intentionally exceed
widthon any line. The renderer truncates overwide non-image lines as a last-resort guard, but components should still return width-safe output. - Measure visual width, not string length: use
visibleWidth(). - Truncate/wrap ANSI-aware text with
truncateToWidth()/wrapTextWithAnsi(). - Sanitize tabs/content from external sources using
replaceTabs()(and higher-level sanitizers in coding-agent render paths).
Minimal pattern:
import { replaceTabs, truncateToWidth } from "@veyyon/tui";
render(width: number): readonly string[] {
return this.lines.map(line => truncateToWidth(replaceTabs(line), width));
}
Input handling and keybindings
Raw key matching
Use matchesKey(data, "...") for navigation keys and combos.
Match app keybinding actions
Extension UI factories receive a KeybindingsManager (interactive mode; an in-memory instance containing the default bindings, not the user’s keybindings.yml) so you can match action ids instead of hardcoding keys:
if (keybindings.matches(data, "app.interrupt")) {
done(undefined);
return;
}
Key release/repeat events
Key release events are filtered unless your component sets:
wantsKeyRelease = true;
Then use isKeyRelease() / isKeyRepeat() if needed.
Focus, overlays, and cursor
TUI.setFocus(component)routes input to that component.- Overlay APIs exist in
TUI(showOverlay,OverlayHandle). In interactive extension/custom UI,custom(..., { overlay: true })mounts your component throughTUI.showOverlay(...); withoutoverlay, it replaces the editor component area directly. - Overlay custom UI is anchored at
bottom-centerwith full terminal width/max height and is removed through the returned overlay handle whendone(...)closes the flow.
Mount points and return contracts
1) Extension UI (ExtensionUIContext)
Current signature (extensibility/extensions/types.ts):
custom<T>(
factory: (
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: { overlay?: boolean },
): Promise<T>
Behavior in interactive mode (extension-ui-controller.ts):
- Saves editor text.
- Without
options.overlay, replaces the editor component with your component. - With
options.overlay, mounts your component as a bottom-centered overlay instead of replacing the editor. - Focuses your component.
- On
done(result): callscomponent.dispose?.(), hides the overlay if present, restores editor + text for non-overlay flows, focuses editor, resolves promise. Sodone(...)is mandatory for completion.
2) Hook/custom-tool UI context (legacy typing)
HookUIContext.custom is typed as (tui, theme, done) in hook/custom-tool types.
Underlying interactive implementation calls factories with (tui, theme, keybindings, done). JS consumers can use the extra arg; type-level compatibility still reflects the 3-arg legacy signature.
Custom tools typically use the same UI entrypoint via the factory-scoped pi.ui object, then return the selected value in normal tool content:
async execute(toolCallId, params, onUpdate, ctx, signal) {
if (!pi.hasUI) {
return { content: [{ type: "text", text: "UI unavailable" }] };
}
const picked = await pi.ui.custom<string | undefined>((tui, theme, done) => {
const component = new MyPickerComponent(done, signal);
return component;
});
return { content: [{ type: "text", text: picked ? `Picked: ${picked}` : "Cancelled" }] };
}
3) Custom tool call/result renderers
Custom tools and extension tools can return components from:
renderCall(args, options, theme)renderResult(result, options, theme, args?)
options currently includes:
expanded: booleanisPartial: booleanspinnerFrame?: number
These renderers are mounted by ToolExecutionComponent.
Lifecycle and cancellation
dispose()is optional at type level but should be implemented when you own timers, subprocesses, watchers, sockets, or overlays.done(...)should be called exactly once from your component flow.- For cancellable long-running UI, pair
CancellableLoaderwithAbortSignaland calldone(...)fromonAbort.
Example cancellation pattern:
const loader = new CancellableLoader(
tui,
theme.fg("accent"),
theme.fg("muted"),
"Working...",
);
loader.onAbort = () => done(undefined);
void doWork(loader.signal).then((result) => done(result));
return loader;
Realistic custom component example (extension command)
import type { Component } from "@veyyon/tui";
import {
SelectList,
matchesKey,
replaceTabs,
truncateToWidth,
} from "@veyyon/tui";
import {
getSelectListTheme,
type ExtensionAPI,
} from "@veyyon/coding-agent";
class Picker implements Component {
list: SelectList;
keybindings: any;
done: (value: string | undefined) => void;
constructor(
items: Array<{ value: string; label: string }>,
keybindings: any,
done: (value: string | undefined) => void,
) {
this.list = new SelectList(items, 8, getSelectListTheme());
this.keybindings = keybindings;
this.done = done;
this.list.onSelect = (item) => this.done(item.value);
this.list.onCancel = () => this.done(undefined);
}
handleInput(data: string): void {
if (this.keybindings.matches(data, "app.interrupt")) {
this.done(undefined);
return;
}
this.list.handleInput(data);
}
render(width: number): readonly string[] {
return this.list
.render(width)
.map((line) => truncateToWidth(replaceTabs(line), width));
}
invalidate(): void {
this.list.invalidate();
}
}
export default function extension(pi: ExtensionAPI): void {
pi.registerCommand("pick-model", {
description: "Pick a model profile",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
const selected = await ctx.ui.custom<string | undefined>(
(tui, theme, keybindings, done) => {
const items = [
{ value: "fast", label: theme.fg("accent", "Fast") },
{ value: "balanced", label: "Balanced" },
{ value: "quality", label: "Quality" },
];
return new Picker(items, keybindings, done);
},
);
if (selected) ctx.ui.notify(`Selected profile: ${selected}`, "info");
},
});
}
Key implementation files
packages/tui/src/tui.ts:Component,Focusable, cursor marker, focus, overlay, input dispatch.packages/tui/src/utils.ts: width/truncation/sanitization primitives.packages/tui/src/keys.ts/keybindings.ts: key parsing and configurable action mapping.packages/coding-agent/src/modes/controllers/extension-ui-controller.ts: interactive mounting/unmounting for extension/hook/custom-tool UI.packages/coding-agent/src/extensibility/extensions/types.ts: extension UI and renderer contracts.packages/coding-agent/src/extensibility/hooks/types.ts: hook UI contract (legacy custom signature).packages/coding-agent/src/extensibility/custom-tools/types.ts: custom tool execute/render contracts.packages/coding-agent/src/modes/components/tool-execution.ts: mountingrenderCall/renderResultcomponents and partial-state options.packages/coding-agent/src/tools/context.ts: tool UI context propagation (hasUI,ui).
Testing and verification
Product behavior is covered by tests that assert concrete outcomes, not only non-empty results.
Examples of what tests check
- Hashline edit path: round-trip: generated patches apply to the intended content; mismatches fail with the expected error surface.
- Tool-call repair: unit and conformance cases in
packages/coding-agent/test/repair/schema-repair.test.ts(clean / repaired / unrepairable, alias ambiguity, strictadditionalProperties). - Tool-output bounds: truncation limits behave as configured and remain visible to the model.
- Architecture gates: layering, import cycles, and module-reach checks in
packages/coding-agent/test/architecture/.
Recording terminal proofs
The capture configuration below is the only source of visual proof. Record interactive proofs on the repository’s private display. Do not record a logged-in desktop, and do not use a terminal multiplexer capture as visual evidence. There are no other capture paths and no fallbacks.
Which artifact proves which change
The artifact class follows the change class, and a mismatch is a failed proof.
static surface changed two PNG frames, before and after
animation or timing changed two animated clips, before and after
setting added or changed two PNG frames, off and on
A still never proves an animation: a frame cannot show a cadence, a transition, or a spinner. An animated clip never substitutes for a frame pair either, because a reader comparing two clips cannot hold both states side by side. The recorder publishes animation as WebP at 33 ms per frame; a GIF is the same clip in an older container and proves the same thing. Both arms of a pair are the same class, produced by one driver run, and attached to the pull request body.
The recorder refuses to publish a clip whose cadence is not the one it captured. Three
criteria come from --expect-ms:
typical frame capture interval +/-1 ms (33/34 ms at 30 fps)
moving average at least 80% of the capture rate, held stills set aside
cadence share at least 85% of moving frames at the capture interval
The typical frame catches a resample, where every frame was rewritten. The moving average catches a clip whose wall clock is mostly slower than its most common frame. The cadence share catches frequent short holds that an acceptable average can hide. Byte-identical frames from normal terminal input and model output are coalesced by the WebP encoder, so the average allows 20% while the share limits how often that occurs. A hold at or past ten intervals is a still screen, is reported, and does not count. Measure a published file with:
python3 proof/webp-cadence.py assets/demo-hd.webp --expect-ms 33
Real interactive sessions
The HD recorder starts Xvfb and kitty inside the recorder container. It drives the shipped CLI with real keyboard and pointer events and records the private display at 30 frames per second.
30 is the rate the pipeline delivers whole. Measured at 2560x1440 with the hero’s chrome and a payload repainting every cell as fast as the terminal accepts it, a 30 fps capture returns 240 unique frames of 240 grabbed. Capturing at 60 adds no motion the session had: it doubles the encoder’s cores and the file, and writes a 60 fps header over slower content, which is how a stuttering take once read as smooth to ffprobe.
A take is judged on whether the picture moved, never on the rate the container declares. proof/motion-gate.sh counts unique frames with mpdecimate and fails a take below SCENE_MOTION_FLOOR; both session scripts run it before the take is published.
The landing-page terminal uses:
terminal kitty
font JetBrains Mono 15
canvas 2560x1440 at 30 fps
window inset 128 px
background #171b22
foreground #d3dae6
publish Lanczos downsample to 1920x1080
Where the settings live, and where the chrome is drawn
proof/docker/scene-config.sh is the single definition of every SCENE_* knob. The two session scripts and the two host recorders source it; none of them restates a default. Override a knob by exporting it, never by editing one of those four files, because a default written down twice is two defaults and the one a run gets depends on which file it entered through.
The chrome — rounded corners, the shadow, the translucent window over the backdrop — is drawn after the take by proof/compose-chrome.sh, not by a compositor during it. The backdrop does not move, so blending it under the window every frame recomputes one static picture thousands of times, and it cost the capture: with picom’s blur on, ffmpeg could grab only 69 of 360 frames, and opacity alone still cost a third. xwallpaper puts the backdrop in the capture for free as a root pixmap; the pass replaces the square-cornered inset with the same pixels rounded, blended and shadowed.
SCENE_CHROME=live runs a compositor during the capture instead, for comparison. It is not the default and a take recorded that way is slower.
The pass is cosmetic. It cannot recover a frame the capture never drew, so a take that stuttered while it was recorded still stutters after it, and the motion gate runs on the composited file that ships.
Preview a scene without replacing tracked proof assets:
PUBLISH=0 DEMO_SERVER=x11 \
PROOF_LLM_BASE_URL=http://<host>:11434/v1 \
bash scripts/demos/record-hd-demo.sh demo-hd
The recorder keeps rehearsal output in the temporary directory it prints. Inspect the video and named frames there. Set PUBLISH=1 only for a complete take whose frame guards all passed.
The scene’s task prompt is static at proof/prompts/demo-hd.md. The scene stores the secret, submits that prompt once, and sends no phase-by-phase operator prompts; every later turn is the model’s own. A take is published only when every named frame guard passed, so a scene whose model does not reach a guarded surface produces a rehearsal and nothing else.
Record on the machine that serves the weights. The endpoint must be a loopback address, or the
recorder will not start; ALLOW_REMOTE_MODEL=1 records against another host and reports it. A
session driven across a network pauses for reasons the recording cannot separate from the product.
Before anything is recorded the driver checks three things and exits on any of them:
bun scripts/verify-scene.ts demo-hd # the same check, run on its own
bun scripts/verify-scene.ts --all
Every string the scene waits for must be produced by the submitted prompt, the product’s own source, the sandbox seed, or a line the scene types. A guard nothing produces does not fail fast: it waits out its timeout, marks the shot missed, and the publish step leaves the previous take’s frame under that name. A needle that comes from somewhere else is declared in the scene:
# needle-source: WARP CORE -- printed by the compiled binary's banner
The driver also requires the model row to exist on that server, and writes <scene>-model.txt
beside the frames recording the row, the endpoint, the host and the display server the take was
recorded on.
Every binary the run will use is resolved before the first frame: docker, bun for the scene
check, and ffmpeg and python3 for the publish chain. ImageMagick answers to magick on 7 and
convert on 6, and either is accepted. Bun is looked for at ~/.bun/bin/bun when it is not on
PATH, because a recording is driven over ssh and a non-login shell there does not carry the
installer’s entry. A publish tool first called after the recording is a take lost to a PATH
difference, which is why a rehearsal needs only docker.
The container is built by one script and tagged from one declaration:
bash proof/docker/build-recorder.sh
The tag contains the bun version in the root package.json packageManager field,
because the image contains a bun and the product will not start on a runtime older
than the one it is built for. A bump therefore makes a stale image a missing image,
which docker reports before a display server starts. Recording with an image built
on an older bun ends the take from inside the container after the whole rig is up.
The recorder reaches a model served on the host through the docker host gateway; the
loopback address the driver requires is rewritten for the container by
proof/docker/host-endpoint.sh, so no scene needs to name a network address.
The archived take remains at capture speed. The landing-page cut keeps the plan, the worker setup, verification and signing at 1×. Visible implementation between the worker launch and the verified build plays at 1.25×. Named marks in the take select those boundaries; untouched screens are shortened to four seconds rather than accelerated.
Settings differentials
A settings change proves with two frames of the settings screen recorded from the
same scene, one with the setting at its default and one with the operator’s value.
SCENE_SETTINGS appends config-file lines to the seeded home before the session
starts, so each arm is seeded rather than toggled by a keybinding that may not land:
OUT_DIR=proof/captures/x11/off \
proof/docker/record-x11.sh proof/scenes/settings-pointer.sh
OUT_DIR=proof/captures/x11/on SCENE_SETTINGS='argot.enabled: true' \
proof/docker/record-x11.sh proof/scenes/settings-pointer.sh
Before-and-after pairs for a UI change
A change to a visible surface proves with two frames of the same scene, one on the tree without the change and one on the tree with it.
proof/docker/record-x11.sh proof/scenes/<name>.sh # the after arm
proof/docker/record-x11-before.sh proof/scenes/<name>.sh # the before arm
The unified workspace search settings surface uses:
OUT_DIR=proof/captures/x11/after \
proof/docker/record-x11.sh proof/scenes/settings-search.sh
proof/docker/record-x11-before.sh proof/scenes/settings-search.sh
The after arm writes to proof/captures/x11/. The before arm writes to
proof/captures/x11/before/, holding every source file the change touched at the
content of the base commit for the length of the run, restoring from an in-memory
copy and proving the restore by sha256. No git mutation command runs and the working
tree ends byte-identical. Once the change is on main, reproducing the before arm
means pointing that hold at the commit before it.
Both arms record the same scene at the same width and are sampled at the same second
of the same script, so the only difference between them is the change. Attach the
labeled Before and After pair to the pull request body. It is never committed: not to
assets/, not to a README, not to a handbook page, not to the website.
A pair whose two arms differ for an unrelated reason is a failed proof. An arm that does not show the surface at all is a failed proof: a lane block is not evidence about lanes in a frame where no agent is running.
Zooming into a detail
A 2560-wide capture published at 1920 loses a small detail to the downsample. A row whose subject is one block of text names the mark to hold on, and the stage eases into the region and back out:
python3 proof/zoom.py take.mp4 zoomed.mp4 --marks take-marks.tsv --mark todo-board
python3 proof/zoom.py --self-check
The region is measured, not typed in: the stage diffs the frames around the moment and holds the bounding box of what changed there, padded and clamped inside the frame at the source aspect ratio. A moment with nothing moving in it produces no file.
The hero take drives the same stage from a cue file the scene writes next to its
marks (zoom-in FRAME [x,y,w,h], pan FRAME x,y,w,h, zoom-out FRAME). The hold between those cues
is at least two seconds of real time, with no time compression on that span, so
the stored secret is readable. Magnification is relative to the published wide
shot and is at least 2x: proof/glyph-height.py measures capture_width / crop_width on the hold. A missing rect on zoom-in means “measure it”.
The zoom ceiling still defaults to the capture width over the published width so a
1.33x hold is a crop. The hero’s 2x secret hold is a tighter crop scaled back to
1920x1080 — a camera move of the one capture path, not a second recorder. The
stage runs on the take, before the cut, and keeps every frame and the recorded
rate, so the cadence gate still measures the capture’s own cadence. A scene asks
for one by writing the cue file, or by setting ZOOM_ARGS in
scripts/demos/record-hd-demo.sh.
--self-check records a synthetic clip whose moving region is known and asserts the
measured rect, the frame count, the rate and the held magnification. Run it on a
recorder host before a take depends on the stage.
Off-screen component renders are a debugging aid, not a proof
Rasterizing a component’s ANSI answers one narrow question quickly and without a display: whether a fill, colour or spacing reads correctly on a grey and a black ground.
env -u NO_COLOR FORCE_COLOR=3 bun scripts/demos/render-<surface>.ts [args] |
bun scripts/demos/render-proof.ts --out /tmp/<surface> --width 100 --scale 2
render-proof.ts writes <surface>-grey.png with background #1e2127 and
<surface>-black.png with background #000000. Inspect both. A background fill
can disappear on black while remaining visible as a slab on grey.
The output draws a fixture written by hand, at a chosen width, through a constructed call, so it cannot show that the surface is reachable, that the state is real, or that the block is positioned, sized and clipped the way a session draws it. It does not satisfy an evidence requirement. Write the file to a temporary path.
scripts/an-off-screen-raster-never-enters-assets.test.ts pins the raster set under
assets/ by exact equality, so the list only shrinks, and fails on a demo driver
that writes a render into assets/. Drivers write outside the tracked tree.
Related
Tool-call repair
Models often emit tool arguments that are almost valid JSON or almost match the tool schema: stringified objects, trailing commas, truncated payloads, or misnamed fields. Without a repair step those calls fail validation and cost a full turn.
Repair runs in the agent loop before argument validation. Clear malformations are coerced into a schema-valid object; ambiguous cases are rejected and returned to the model as an error tool result (no dispatch).
Behavior
| Step | What it does |
|---|---|
| Seam | Runs at tool dispatch, before schema validation |
| Fix-if-clear | Trailing commas, parse sentinels (__parseError / __rawJson), stringified JSON objects |
| Refuse-if-ambiguous | Missing required strings with multiple plausible sources → unrepairable |
| Alias / typo rename | Unknown keys that clearly map to a declared property are renamed; ambiguous renames are rejected |
| Strict unknown keys | Schemas with additionalProperties: false reject leftover keys after alias resolution |
| Size bound | Inputs over 1 MiB are not repaired |
| Disable | VEYYON_REPAIR_DISABLE=1, or per-model harness.profiles with repair: false |
Implementation: packages/coding-agent/src/repair/schema-repair.ts
Tests: packages/coding-agent/test/repair/schema-repair.test.ts
Related
- The repair cascade: ordered rules
- Per-model posture: harness profiles
- Soundness and telemetry
- The hashline edit engine: edit path (separate from argument repair)
The repair cascade
Before argument validation, the agent loop runs packages/coding-agent/src/repair/schema-repair.ts in this order:
- Parse leniency: trailing commas / relaxed JSON; stringified argument blobs.
- Alias / typo key rename: unknown keys that match a common alias (
filepath→path,contents→content) or a casing/separator typo of a declared property are renamed to the declared name. Refuse when the rename would be ambiguous (two unknown keys map to the same property, one unknown key matches more than one declared property, or the alias target already has a value). - Strict unknown-key rejection: when the tool schema declares
additionalProperties: false, any key left after alias resolution is rejected rather than dropped or passed through. - Ambiguity guard: reject when required string fields have multiple plausible donors.
- Outcome:
clean,repaired(canonical args + hints), orunrepairable(error tool result, no dispatch).
Conformance suite: packages/coding-agent/test/repair/schema-repair.test.ts (alias renames, ambiguity refusals, strict-mode refusals, and a guard that strict rejection does not fire on ArkType/Zod wire schemas that synthesize additionalProperties: false for closed-object emission rather than authorial strictness).
Per-model enable/disable and tool allowlist hints: Per-model posture.
Related
Per-model repair posture
The repair hook receives the active model id so behavior can vary by model. Configure overrides with harness profiles: harness.profiles in config.yml, or harness-profiles.yml in the agent dir. Keys are provider/model-id or provider/* wildcards.
harness:
profiles:
"anthropic/claude-sonnet-4-20250514":
repair: true
tools: ["read", "edit", "search", "bash"]
promptSectionOrder: ["tool-policy", "delivery-contract"]
"google/*":
repair: false
| Field | Effect |
|---|---|
repair: false | Skip schema repair for that model |
tools: [...] | Filter the initial tool allowlist |
promptSectionOrder: [...] | Reorder default system-prompt banner sections |
Addressable banner sections are role, runtime, tool-policy, execution-workflow, delivery-contract, project, shorthand, and shorthand-handles. Listed sections move first in the order you provide, after the fixed system-conventions preamble. Unlisted sections keep section-registry order. One provider-cache boundary remains: runtime sections (project, shorthand, and shorthand-handles) cannot move ahead of the static statement-assembled prefix. If you list a runtime section before a static section, Veyyon keeps the cache boundary and logs a warning. An unknown or non-string section rejects the whole list with a warning, so a hand-edited file cannot apply an order you did not write. tools follows the same rule: one invalid entry drops the whole allowlist rather than silently denying the model a tool. A harness-profiles.yml file that cannot be read or parsed is reported with its path and reason, and no profiles take effect. Custom system prompts have no banner sections, so Veyyon ignores this setting with a warning.
Disable all repair process-wide: VEYYON_REPAIR_DISABLE=1.
See Why repair exists and Models.
Observability for repair and sessions
Usage and sessions
/usagein the TUI andveyyon statson the CLI: token and usage views- Status line token accounting (
token_*,context_pct,cost) - Coding-agent structured logger
OpenTelemetry
When OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is set, the process registers an OTLP/protobuf trace exporter and exports agent-loop spans (invoke_agent, chat, execute_tool, …). Standard OTEL_* env vars apply (OTEL_SERVICE_NAME, headers, OTEL_SDK_DISABLED, OTEL_TRACES_EXPORTER=none). Transport is http/protobuf only. See packages/coding-agent/src/telemetry-export.ts.
Related
The hashline edit engine
Default edit mode is hashline (edit.mode: hashline in config.yml), implemented in
@veyyon/hashline.
Veyyon applies file changes through the edit tool (hashline patch language by default). The
model copies [PATH#TAG] anchors from read / search / write output, then emits SWAP, DEL,
and INS operations against numbered lines. Snapshot tags detect stale anchors and drive recovery.
Alternate modes (apply_patch, patch, replace) exist for compatibility; hashline is the
default and the path Veyyon optimizes for.
How a hashline edit works
readorsearchrecords a whole-file snapshot and prints[relative/path#TAG]plusLINE:contentrows (TAGis a four-hex snapshot id).- The model sends
editwith aninputstring: one or more[PATH#TAG]sections and hashline ops (SWAP N.=M:,DEL N.=M,INS.PRE N:/INS.POST N:/INS.HEAD/INS.TAIL, block opsSWAP.BLK/DEL.BLK/INS.BLK.POST, plus whole-fileREMandMV DEST). @veyyon/hashlineparses, verifies the tag against the snapshot store, applies ops, and returns a fresh[path#TAG]header plus a compact diff preview.writecan create or overwrite whole files; in hashline display mode it also mints snapshot headers for the next edit.
edit.mode and VEYYON_EDIT_VARIANT select among hashline, apply_patch, patch, and replace.
Invariants
| Property | Behavior |
|---|---|
| Stale anchor | Mismatch errors name the tag; snapshot recovery can suggest the current file hash |
| Line numbers | 1-indexed; body rows use +TEXT prefix |
| Order | Non-overlapping hunks; overlapping regions fail with an error |
| Encoding | Applies to normalized content; BOM and dominant line ending preserved on write |
Further reading
- User guide: Editing and repair
- Tool contract:
docs/tools/edit.md - Read/search anchors:
docs/tools/read.md,docs/tools/search.md - Settings:
edit.modeindocs/handbook/src/reference/settings.md
There is no veyyon-edit Rust crate, no V4A-only write path, and no make_update_patch envelope
routing. General schema-based tool-call repair is shipped, see
Repair overview.
Repair on edits
Schema-based tool-call repair runs on all tools, including edit, before argument validation. Hashline parsing and verification inside @veyyon/hashline is a separate step.
When a model emits a malformed edit (or compatibility apply_patch) call:
- Schema repair attempts JSON recovery and ambiguity refusal at the agent-loop seam.
- If repair succeeds, arguments proceed to hashline / apply_patch validation and dispatch.
- If repair cannot disambiguate, the loop returns an error tool result with hints (no dispatch).
- Hashline still applies envelope stripping and bare-body handling inside
@veyyon/hashline.
See Repair overview and The hashline edit engine.
Edit-path properties
Correctness properties of the edit path. The default engine is hashline (@veyyon/hashline); apply_patch, patch, and replace remain as compatibility modes. See The hashline edit engine.
BOM and line endings
Edits must not silently rewrite encoding. The path strips a leading UTF-8 BOM before matching and restores it afterward, and restores the file’s dominant line ending (CRLF or LF) on write. Matching runs against a normalized LF body. Without this, one edit can rewrite CRLF to LF or drop a BOM.
Multi-edit in one call
The edit tool can apply several disjoint changes in one call. Anchors match against the original file (not incrementally); replacements are ordered so earlier growth does not invalidate later matches. Ambiguous anchors, overlapping regions, and no-ops fail with an actionable error.
Hashline
Hashline avoids re-echoing surrounding text: the model references spans by [PATH#TAG] snapshot anchors from read / search / write, then sends operations (SWAP, DEL, INS) and new text. Stale tags fail verification instead of applying a wrong edit. Default: edit.mode: hashline in config.yml.
Concurrency
Non-parallel tools take an exclusive lock so file mutations from one turn are serialized. Same-file concurrent edits from subagents still need care; independent files may proceed under the tool locking rules.
Trailing newlines
The write path normalizes by ensuring a trailing newline on the final line when applying some write forms. Tests cover this behavior; see edit/write tool tests in packages/coding-agent.
The provider stack and bring-your-own-key
The harness provides the model registry and provider auth.
A provider is the API namespace (anthropic, openai, google, custom gateways, local
ollama, …). A model is provider/model-id. Veyyon assembles the selectable catalog from:
- Bundled pi-catalog models
~/.veyyon/profiles/default/agent/models.ymlcustom providers and models- Runtime discovery (Ollama, LM Studio, discovery-enabled gateways)
- Extension-registered providers
A model is available when its provider is not disabled and credentials resolve (or the provider is keyless/local).
Credentials
Resolution order (first match wins):
- CLI
--api-key(ephemeral) models.ymlapiKeyon a custom provider- Stored API key / OAuth in the agent auth store (
~/.veyyon/profiles/default/agent/agent.db) - Provider environment variables (see
docs/handbook/src/reference/providers.md) - Custom fallback resolvers in
models.yml
Use /login, /logout, or veyyon OAuth flows in setup. Provider-scoped logins do not cross
providers.
Custom providers
Add OpenAI- or Anthropic-compatible endpoints as data:
# ~/.veyyon/profiles/default/agent/models.yml
providers:
my-gateway:
baseUrl: https://api.example.com/v1
api: openai-completions
apiKey: MY_GATEWAY_API_KEY
models:
- id: claude-sonnet
name: Claude Sonnet via Gateway
contextWindow: 200000
maxTokens: 8192
Validate with veyyon models list and /model.
Local engines
ollama, llama.cpp, and lm-studio are treated as keyless when the engine responds. Each has its
own discovery variable, not a shared VEYYON_OSS_* pair: OLLAMA_BASE_URL (or OLLAMA_HOST),
LLAMA_CPP_BASE_URL, LM_STUDIO_BASE_URL, see Environment variables.
User guides: Models, Configuring providers.
There is no separate backends.toml catalog subsystem; Veyyon uses models.yml plus the bundled
catalog.
Execution-order prompts
The harness assembles system and developer prompts and adapts them per provider. Base instructions
encode control-flow discipline: explore → plan → edit → verify → STOP. Plan mode (/plan) and
goal mode (/goal) add gating on top of the default prompt stack.
Delivery
- A default system prompt plus per-tool prompts
- Per-provider streaming and tool wire format
- Skills and rules inject additional context via discovery
Edit tool prompts switch with edit.mode (the hashline prompt when hashline is active).
There is no backends.toml-driven catalog or per-backend prompt tuning, and apply_patch is not the
default edit surface, Veyyon uses hashline by default.
Seeing every prompt
You do not have to read the source to find out what Veyyon sends a model. Run:
veyyon prompt --prompts
That lists every prompt by id, grouped by the directory it lives in, with one line on what
each is for. An id is the file’s path under that directory without the .md, so
turn-control/auto-continue and dialect/gemma name their own files.
Then look at one:
veyyon prompt --prompt subagent/system-prompt
The lookup spans every registry, so an id from any of them works without specifying its package. A mistyped id is rejected with the nearest real id quoted back.
For the system prompt itself, veyyon prompt prints the assembled text and
veyyon prompt --sections breaks it down by section with the byte and token cost of each.
veyyon prompt --statements goes one level finer. The prompt is assembled from named statements,
one per rule, so this prints what each individual rule costs you:
statement bytes tokens share condition
execution-workflow/verify 1099 275 10.6% always
delivery-contract/personality 1098 274 10.5% personality
tool-policy/lsp 412 103 4.0% tools has lsp
Two things to read from it. The cost is MARGINAL: it is what the prompt would be shorter by without that rule, not the length of the rule’s text, so the numbers add up to their section rather than exceeding it. And the condition states what turns the rule on, which is what you need to know before deciding a rule is not earning its tokens.
Under the table is every rule this configuration leaves out, with the condition that would include it, so a rule being off is visible as a fact rather than as an absence you have to notice:
not in this prompt (33 of 68):
tool-policy/delegation-gates needs tools has task
runtime/obsidian-vault-url needs hasObsidian
To read one of those rules, name it:
veyyon prompt --statement delivery-contract/personality
You get the rule’s rendered text, which is what the model sees rather than the template behind it. If the rule is not in this prompt you get the condition that would include it and why the rule exists, and the command still exits 0, because a rule being off is a configuration and not a failure. An id that does not exist exits non-zero and quotes the ids of the section you named.
Both read your real configuration. The settings the prompt is gated on – your personality, whether
subagent delegation is preferred or required, whether Mermaid diagrams are rendered, which tool
dialect applies – are resolved from your profile config.yml before the prompt is
assembled, so what you see is what a session would send. Change a setting, run it
again, and the difference is visible.
The prompt is only half of what a turn pays before your first message. Every active tool ships a
description and a parameter schema on every request, and veyyon prompt --tools prices that half:
tool bytes desc schema tokens share
edit 8158 2013 27 2040 15.4%
eval 5720 1258 173 1431 10.8%
launch 5563 684 707 1391 10.5%
TOTAL 53060 10011 3266 13277
17 tools cost 13277 tokens; the system prompt costs 23403. Every request pays both.
The row set is the tool set your configuration loads, so disabling a tool removes its row and its cost. The two halves are separated because they are cut differently: a description is prose you can shorten, a schema is the parameter list and shrinks only by dropping parameters.
Nothing is written while you look. The command opens no database, migrates nothing, and leaves no marker files, so inspecting the prompt cannot change what the next session does.
Going deeper
The system prompt is not one string. It is an ordered list of parts, and the boundary between the
first part and the rest is a provider-caching contract rather than a stylistic choice. To change
what a part contains, read System prompt customization. To
understand why the parts are split where they are, and where a new part would belong, read
docs/internal/system-prompt-architecture.md.
System Prompt Customization
How the coding-agent assembles the system prompt sent to the model, and what you can control.
The system prompt is ASSEMBLED. It is composed from the section registry and from statements gated on your settings; there is no file on disk holding its text for you to edit. PROMPT_SECTIONS/ is how you change what a section contains, per statement and validated. --system-prompt remains for a caller that supplies its own prompt for one invocation (the SDK, an eval harness).
For the implementation side of the same subsystem, the block/tier model, the ordering rules, and how to decide where a new section belongs, see System prompt architecture, and for how the cached prefix is marked on the wire see Prompt caching.
Veyyon no longer reads a SYSTEM.md or APPEND_SYSTEM.md file from disk.
SYSTEM.md replaced the whole assembled prompt with hand-written text. APPEND_SYSTEM.md added text to the end of it, which is what AGENTS.md already does, at more scopes and with a directory walk-up that APPEND_SYSTEM.md never had. Both were discovered out of any repository you entered, and a new profile copied them along under a checkbox labelled AGENTS.md.
To add instructions, write them in AGENTS.md. To change the text of a prompt section, use PROMPT_SECTIONS/. If either removed file is still on disk, veyyon reports it at launch and points at the replacement rather than ignoring it in silence.
Primary implementation:
packages/coding-agent/src/system-prompt.ts(buildSystemPrompt)packages/coding-agent/src/main.ts(resolves the two prompt flags; there is no file discovery for either)packages/coding-agent/src/prompts/<directory>/rows.ts(the prompts one directory owns, each with its id and purpose) andpackages/coding-agent/src/prompts/registry.ts(which aggregates all of them)packages/utils/src/prompt-registry.ts(what a registry IS: the row shape,definePromptRegistry, andrequirePromptFrom, the one lookup that rejects an unknown id)packages/coding-agent/src/system-prompt-builder/banner-grammar.ts(what a banner IS for every prompt: how one is written, how one is recognised, andsplitBanneredDocument, the one parser that cuts a prompt at its banners)packages/coding-agent/src/system-prompt-builder/section-registry.ts(the section registry: which sections exist, their banners, and their order)packages/coding-agent/src/system-prompt-builder/prompt-sections.ts(the system prompt’s own section names and the reordering a harness profile specifies)packages/coding-agent/src/prompts/session/system-prompt.md(zero-prose outer scaffold containing only{{templateSections}})packages/coding-agent/src/prompts/session/custom-system-prompt.md(the base template a caller-supplied prompt switches to)packages/coding-agent/src/prompts/session/project-prompt.md(project/environment footer)packages/coding-agent/src/utils/host-environment.ts(the workstation rows that footer renders: OS, kernel, arch, CPU, GPU, terminal)
Where prompts live
A package defines its own prompts. Each package that ships any keeps them under its own src/prompts/ directory with a registry.ts beside them, and that registry is the only module allowed to import one:
| Package | Prompts directory | What is in it |
|---|---|---|
@veyyon/coding-agent | packages/coding-agent/src/prompts/ | the system prompt, the subagent prompt, tool descriptions, and every turn the agent takes on its own behalf |
@veyyon/agent-core | packages/agent/src/prompts/ | compaction: summarizing a session, branch summaries, handoff documents |
@veyyon/ai | packages/ai/src/prompts/ | one format guide per tool-call dialect, plus the tool-catalog template that contains them |
@veyyon/hashline | packages/hashline/src/ | the hashline patch language, which is the edit tool’s description |
@veyyon/evals | packages/evals/suites/typescript-edit/prompts/ | the edit benchmark’s task, system, and retry prompts |
An id is the file’s path under its registry’s directory without the .md, so turn-control/auto-continue is packages/coding-agent/src/prompts/turn-control/auto-continue.md and dialect/gemma is packages/ai/src/prompts/dialect/gemma.md. Ids are unique across the registries, so you never have to name the package to ask about a prompt. @veyyon/hashline is the one package whose prompt is not under a prompts/ directory: its single file is published at @veyyon/hashline/prompt.md for anyone embedding hashline in their own agent, so moving it would break a public subpath.
To find a prompt, read the registry or run veyyon prompt --prompts, which lists every id in the four product registries under its directory, with a line saying what it is for. The benchmark harness’s prompts are not listed there: they are used by a measurement tool rather than by the agent.
The registries are the whole set by construction, not by anyone remembering to add a row. The import is the registration, so a prompt file with no row is unreachable code, and prompt-registry-coverage.test.ts fails if the set on disk and the set in a registry disagree in either direction, or if any module outside a registry imports a .md as text.
A registry is one definePromptRegistry(dir, rows) call, and the descriptor it returns is what other code takes. That matters for the same reason the rest of this section does: the directory is stated once, in that call, and veyyon prompt, the coverage suite and the generated inventory read it off the descriptor instead of each writing the path again. They used to write it again, and the inventory’s copy had gone stale, listing three directories while claiming one per package. The same test fails if a directory is written down twice.
A descriptor gives you dir, prompts, ids, text(id), require(id), has(id) and fileFor(id). Use prompts["some/id"].text where the id is a literal, since that is checked at compile time; use require(id) where the id comes from a variable, because it throws on an unknown one rather than handing back a prompt with no text.
1) Inputs
Two user-controllable inputs feed prompt assembly. Each resolves as either a literal string or, if the argument is a path to a readable file, the contents of that file (resolvePromptInput).
A value that fails to read is an error when it has no spaces and either contains a path separator or ends in a prompt-file extension (.md, .markdown, .txt, .text, .prompt). --system-prompt ./promtps/main.md states the path and the reason rather than quietly using the string ./promtps/main.md as your whole system prompt. Prompt text is unaffected: a one-line prompt containing a slash, or ending in a dotted word, is used as written. To pass text that would otherwise read as a path, put it on more than one line, since no path contains a newline.
| Input | Source | Effect |
|---|---|---|
--system-prompt <text-or-file> | CLI flag | Replaces block 0: the default stable instructions. Highest precedence. |
--append-system-prompt <text-or-file> | CLI flag | Adds a prompt block. Without a custom system prompt it goes after all default blocks; with one it goes after the custom block and before the preserved project/environment footer. |
Both are per-invocation flags, for a caller that supplies its own prompt for one run: the SDK, an eval harness, a benchmark adapter. Neither has a file on disk that veyyon discovers on your behalf.
Neither flag is discovered from a file, so there is no precedence list to learn and no path where a repository you enter supplies prompt text. Instruction files still work the way they always have: AGENTS.md is discovered from the global location, the active profile, and every directory walked up from the working directory to the repository root, and it is inlined into the prompt. See docs/handbook/src/architecture/config.md for the discovery contract.
2) Replace vs. append
Normal CLI startup resolves the two flag values, then hands them to prompt assembly in packages/coding-agent/src/main.ts:
export function applyResolvedSystemPromptInputs(
options: CreateAgentSessionOptions,
resolvedSystemPrompt: string | undefined,
resolvedAppendPrompt: string | undefined,
): void {
if (resolvedSystemPrompt) {
options.customSystemPrompt = resolvedSystemPrompt;
}
if (resolvedAppendPrompt) {
options.appendSystemPrompt = resolvedAppendPrompt;
}
}
buildSystemPrompt in packages/coding-agent/src/system-prompt.ts then selects the base template:
- No custom prompt: statement modules assemble every stable instruction section. The section registry supplies section identity, order, and banners. The zero-prose
system-prompt.mdscaffold contributes only the{{templateSections}}slot. - Custom prompt present: the base switches to
session/custom-system-prompt.md, which renders your custom text plus the append prompt, context files, discovered skills, always-apply rules, and rules. The stable statement modules, tool inventory, and default workflow guidance are not rendered.
In both cases the dynamic project/environment footer from project-prompt.md still renders after the base template. It includes workstation information, the active profile name, the agent and skills directories, the global and profile AGENTS.md paths, the directory-context list, workspace tree, date, and cwd. With a custom prompt the footer omits context files and the append prompt because the custom template already rendered them.
Consequences for normal CLI use:
- Passing
--system-promptreplaces the stable default instructions and tool inventory. Context files, skills, always-apply rules, and rules are kept (the custom template renders them), and the dynamic project/environment footer remains. - Passing
--append-system-promptwithout a custom system prompt appends your text after the default instructions. - Passing both produces: custom system prompt text, append prompt text, then the kept skills/rules/context files and the dynamic project/environment footer.
For everyday use you want neither flag. To add instructions, write AGENTS.md. To change the text of one section, use PROMPT_SECTIONS/.
3) Templating contract
Contents of --system-prompt and --append-system-prompt are treated as plain text. They are resolved before prompt-block replacement and are not rendered as Handlebars templates.
An explicitly supplied empty or whitespace-only custom prompt is still a replacement. It does not fall back to the shipped statements. If it renders no base content, Veyyon omits the empty provider block and keeps the dynamic project footer.
The built-in prompt templates are Handlebars (packages/utils/src/prompt.ts), but user-provided strings are not compiled with that renderer. The assembler inserts each resolved flag value into a Handlebars parent template as a string. Handlebars does not recursively render substituted text. Concretely:
{{! parent template, handled by Handlebars }}
{{customPrompt}}
If the value passed to --system-prompt contains:
Working in {{cwd}} on {{date}}.
{{#if hasMemoryRoot}}Memory enabled.{{/if}}
the rendered output contains those characters verbatim, {{cwd}}, {{#if hasMemoryRoot}}, etc. are NOT substituted. They will be shown to the model as literal Handlebars syntax.
This is by design. The internal template variables (cwd, date, environment, workspaceTree, skills, rules, toolRefs, hasMemoryRoot, hasObsidian, mcpDiscoveryServerSummaries, …) are not a supported public surface, they change between releases as the prompt is rewritten, and they would couple user configs to internals. Treat them as private.
There is no supported public templating surface for a caller-supplied prompt. Write plain text (or markdown) only.
4) Recommended patterns
“Tweak the default”: keep default, add a few rules
Write an AGENTS.md. The default instructions and the project footer stay intact, and your text is inlined into the prompt with the other context files. This is the everyday answer, and it needs no flag.
# ~/.veyyon/profiles/default/agent/AGENTS.md
Prefer Bun APIs over Node APIs in this project.
When you change a public function, run `bun check` before yielding.
An AGENTS.md beside the code applies to that project; the one in your profile applies to every session in that profile; the global one applies everywhere. All of them are discovered for you.
“Replace the stable default instructions”: bring your own base prompt
Pass --system-prompt. You replace the stable default instructions in block 0, but startup still preserves the dynamic project/environment footer block (project-prompt.md): workstation info, context files, dir-context list, workspace tree, current date, cwd, and related project context.
$ veyyon --system-prompt ./reviewer-prompt.md
There is no file veyyon picks up on its own for this. A prompt that replaces the whole assembly is a per-invocation decision by a caller who wants exactly that, not a setting that follows you into every session.
Reach for this only when you want a genuinely different base prompt. If you are keeping most of the default and changing one part, use PROMPT_SECTIONS/ instead (section 8): it edits a single section and leaves the rest as shipped, so you do not have to maintain a copy of the default tool guidance, exploration rules, or workflow rules.
“Customize while keeping the tool inventory and default workflow guidance”
Use AGENTS.md, not --system-prompt. The tool inventory, role guidance, and default exploration and workflow rules come from statement modules in src/system-prompt-builder/statements/. A custom system prompt switches to session/custom-system-prompt.md, so those default statements are not available to the model.
A custom system prompt still keeps the generated project content: session/custom-system-prompt.md renders context files, discovered skills, always-apply rules, and rules alongside your text, and the project-prompt.md footer still carries workstation info, the workspace tree, the current date, and cwd.
If you wanted a full replacement only to change one part, use PROMPT_SECTIONS/ (section 8). It replaces or appends to one registry section and keeps every other statement module.
“Customize automatic session titles”
The system prompt does not affect the model call that titles a new session. Create the title-specific prompt file instead:
# ~/.veyyon/profiles/default/agent/TITLE_SYSTEM.md
Generate a session name using lowercase `<type>:<primary-objective>`.
If the message contains no concrete task, output exactly `none`.
TITLE_SYSTEM.md is discovered project-first, then user, across the config bases. It is a prompt for a side call that titles a session, not the agent’s own system prompt, which is why it is still a file. When absent, Veyyon uses the bundled title-system.md / tiny-title-system.md prompts. When present, both the online title path and the local tiny-model path keep the <title>...</title> wrapper while using this file as the system turn.
“Replace everything, including project context”: SDK-only
The CLI flag path intentionally preserves defaultPrompt.slice(1). Code using CreateAgentSessionOptions.systemPrompt directly can return a full replacement array and omit the project footer, but that is not what --system-prompt does.
“Change one section of the default instructions, keep the rest”
Use PROMPT_SECTIONS/, described in section 8. Put your text in PROMPT_SECTIONS/<section>.append.md to add to a section, or PROMPT_SECTIONS/<section>.md to replace it. Every other section stays exactly as shipped, including the generated skills, rules, and tool guidance, so this is the option to reach for whenever you want to change one thing rather than own the whole prompt.
Run veyyon prompt --sections to see the section names for your configuration.
5) Deduplication
A custom base and an append value are each rendered once. dedupeAlwaysApplyRules also omits an always-apply rule when its body already appears verbatim in the custom base, append value, or a loaded context file.
6) Discovery paths
Veyyon does not discover a whole-prompt replacement or append file. Both whole-prompt inputs are flags. Persistent PROMPT_SECTIONS/ files remain discoverable because each file targets one validated assembled section instead of bypassing assembly.
The instruction files that ARE discovered are a different mechanism, and they still walk the tree: AGENTS.md is read from the global location, from the active profile’s agent directory, and from every directory between the working directory and the repository root. See docs/handbook/src/architecture/config.md.
7) Quick reference
| Goal | Use |
|---|---|
| Add an instruction on top of the full default prompt | AGENTS.md (profile, global, or beside the code) |
| Change one section and keep the rest | PROMPT_SECTIONS/<section>.append.md (see section 8) |
| Replace the stable default instructions for one run | --system-prompt |
| Append text for one run | --append-system-prompt |
| Customize automatic session titles | TITLE_SYSTEM.md; the agent’s own prompt does not affect title generation |
Use {{cwd}} / {{date}} / other internals in my file | Not supported. Caller-supplied prompts are inserted verbatim. |
| See the prompt a configuration actually produces | veyyon prompt (see section 9) |
| Change instructions per repository | An AGENTS.md in that repository |
| Change instructions everywhere | The global AGENTS.md, or the one in your profile’s agent directory |
8) Changing one section: PROMPT_SECTIONS/
--system-prompt replaces the stable default template. The generated project footer, context files, discovered skills, and rules remain, but the tool inventory, default workflow guidance, and settings-gated default sections do not render. If you only want to add a rule or reword one part, use the narrower mechanism.
PROMPT_SECTIONS/ changes one section and leaves the others exactly as shipped.
The default template is a sequence of named sections. To see the names for your configuration, run:
veyyon prompt --sections
Put a file named after a section in a PROMPT_SECTIONS/ directory under the active profile’s agent dir:
~/.veyyon/profiles/default/agent/PROMPT_SECTIONS/ # default profile
~/.veyyon/profiles/<name>/agent/PROMPT_SECTIONS/ # named profile
The active profile is the only location. A repository’s .veyyon/PROMPT_SECTIONS/ used to be read and could replace a shipped section outright; a working tree no longer contributes prompt sections.
Two filename forms decide what happens:
| File | Effect |
|---|---|
<section>.append.md | Your text is added at the end of that section. The shipped text stays, including anything added to it in a later release. |
<section>.md | Your body text replaces that section. The section registry adds the canonical banner. |
Prefer append. It survives upgrades, because the shipped section is reused rather than copied.
To add a rule to the delivery contract:
# ~/.veyyon/profiles/default/agent/PROMPT_SECTIONS/delivery-contract.append.md
Always include the exact command you ran when you report a test result.
Everything else in the prompt is untouched. Overriding one section never changes another, and never disables a setting-gated block in a different section.
A few rules worth knowing:
- A file that specifies a section that does not exist is an error, not a no-op. The message lists the valid names. A typo that silently did nothing would leave you believing a change was live when it was not.
- Section names are the ids
veyyon prompt --sectionsprints:conventions,role,runtime,tool-policy,execution-workflow,delivery-contract. ThesystemPrompt.sectionOverridesconfig key accepts the same ids, and also accepts the camelCase spelling (toolPolicy) that the SDK uses for its property names. Both reach the same section, so you can use the id everywhere and never think about the difference. - Replacement and append files contain section body text only. Do not copy any registered
NAMEand==============banner into the file. The section registry adds the target section’s canonical banner, and rejects any banner-shaped text that could manufacture a second section. An empty or whitespace-only append file is a no-op. PROMPT_SECTIONS/cannot be combined with--system-prompt. A custom prompt has no sections to override, so asking for both is an error rather than a silent choice between them.- A directory that is not there means you have no overrides, and that is the ordinary case. A directory that IS there and cannot be read is an error stating the path and the reason, as is a file inside it that cannot be opened. Both would otherwise run the shipped prompt while your files sat on disk looking applied.
9) Seeing the prompt: veyyon prompt
system-prompt.md is a zero-prose scaffold containing only {{templateSections}}. It is not a useful way to inspect instructions. The text comes from statement modules, and conditions depend on the active tools, settings, workspace, and model.
veyyon prompt prints the assembled prompt for your current configuration, without starting a session.
veyyon prompt # the full assembled prompt
veyyon prompt --sections # a size breakdown, largest section first
veyyon prompt --section role # one section's text
veyyon prompt --json # the same breakdown, machine readable
veyyon prompt --no-tools # assemble with no tools
veyyon prompt --tools # what each active tool description and schema costs
--sections answers “what is taking up my prompt”:
section source block bytes tokens share
project runtime 1 23191 5798 62.2%
tool-policy template 0 4406 1102 11.8%
delivery-contract template 0 4053 1014 10.9%
The source column describes the provider-cache source class. template means a static statement-assembled section in block 0. It does not mean prose comes from system-prompt.md. runtime means a separately emitted section computed from workspace or session state.
Under the table you get the sections that are NOT in this prompt:
not in this prompt:
shorthand optional the shorthand notation block, taught when the encode gate is open
shorthand-handles optional the handle table for loaded projects
This is the difference between a prompt that is small and one that is broken. An optional section is absent because its feature is off, which is ordinary. A REQUIRED section is absent because assembly failed, and the command reports it and exits 1:
1 REQUIRED section did not render (role). This prompt is incomplete, not minimal.
Every other exit is 0, so veyyon prompt --sections works as a check in a script. --json contains the same information in a missing array, present even when empty.
The block column is the index of the part in the ordered array buildSystemPrompt returns. Block 0 is the static prefix that providers cache; later blocks hold text that changes often. Each provider serializes those parts its own way (Anthropic sends them as separate system text blocks, most OpenAI-wire paths as separate system or developer messages, Gemini as separate systemInstruction parts), so a block is a separate part, not necessarily a separate message. Moving content from a later block into block 0 would break the cache, which is why the breakdown reports the boundary rather than hiding it. See System prompt architecture for the per-provider mapping.
--no-tools is useful for finding tool-gated text: run it, diff against the normal output, and every line that disappeared was behind a tool being available.
Use --json to compare two configurations mechanically, for example to check that a settings change altered only the section you expected.
The other prompts
The system prompt is not the only prompt a model receives. Delegated tasks run under a subagent prompt, and there are separate prompts for summarizing a session, titling it, writing a commit message, classifying a turn, teaching a model how to write a tool call, and more. List them with:
veyyon prompt --prompts
Then look at one:
veyyon prompt --prompt subagent/system-prompt
That reports the prompt’s sections and which of them are optional, so you can tell a subagent prompt that rendered three of its five sections because the task had no plan and no worktree from one that lost two sections to a bug.
Most of these prompts are a single region with no internal structure, and they report one body section. The subagent prompt has five: role, context, plan, coop, and completion.
The list is grouped by the directory each prompt lives in, and the lookup spans all four groups, so veyyon prompt --prompt compaction/summarization-system and veyyon prompt --prompt dialect/gemma work the same way as one from the coding agent’s own tree. A mistyped id is rejected with the nearest registered id quoted back, rather than printing an empty description that would read as a prompt with nothing in it.
10) Adding a section (contributors)
The sections above are data, not code. section-registry.ts holds two registries, and everything else about a section is derived from its row there.
TEMPLATE_SECTIONS describes the static cached-prefix sections assembled from statements. RUNTIME_SECTIONS describes separately emitted sections, and each runtime row states where its text comes from:
{ id: "project", source: "runtime", name: "PROJECT",
input: { kind: "computed" }, purpose: "..." }
{ id: "shorthand", source: "runtime", name: "SHORTHAND",
input: { kind: "option", key: "argotPreamble" }, purpose: "..." }
computed means buildSystemPrompt produces the text. option means a caller passes it in under the named key, which is the shape a settings-gated preamble takes: the setting is read in sdk.ts, and the option contains the rendered text.
Adding one is two edits.
First, add the id to RUNTIME_SECTION_IDS and a row to RUNTIME_SECTIONS:
{
id: "house-style",
source: "runtime",
name: "HOUSE STYLE",
input: { kind: "option", key: "houseStylePreamble" },
purpose: "the project's writing conventions, when the setting is on",
optional: true,
}
A row declares the banner’s NAME, never the rendered banner. banner-grammar.ts
defines the = underline for every prompt in the product, so a section cannot ship a
width of its own: renderBanner writes one, leadingBannerName reads one back, and
bannerTable turns a set of rows into the table a splitter is driven by. Anything
that needs to know what a banner looks like reads that module rather than spelling
the rule out again.
The row’s own fields (id, name, purpose, optional) come from PromptSection
in packages/utils/src/prompt-registry.ts. TemplateSection and RuntimeSection
extend it with the two things only the system prompt needs, source and input, and
every other registry uses it as it is. The grammar and the row shape are separate
because they answer separate questions: the grammar sets what the bytes look like,
the row states what a section claims about itself.
optional states whether the section may be absent, and it is checked rather than
believed. A settings-gated section is optional: true, because it disappears when
its setting is off. Mark one false and it must render from the barest options the
builder accepts; mark one true and it must be absent until its input is supplied.
system-prompt-section-presence.test.ts holds both directions, so the flag cannot
become a comment that stopped being true.
Second, declare that key on BuildSystemPromptOptions in system-prompt.ts:
/** The house-style preamble, present when the `houseStyle` setting is on. */
houseStylePreamble?: string;
That is the whole change. The assembler reads the registry, so it needs no edit: the section is emitted in registry order, under its own banner, and omitted entirely when the option is absent rather than rendered as a bare heading.
Four mistakes are caught rather than shipped. Three are compile errors:
- Naming an option that is not a field of
BuildSystemPromptOptions. The error states the offending key. - Declaring the field as something other than a string.
- Marking the section
computedwithout givingcomputedTextan entry for it.
Two more are test failures rather than compile errors, because no type can see them. Declaring an option and never setting it in sdk.ts leaves the section permanently empty; system-prompt-wiring.test.ts fails if a declared option has no production caller. And getting optional wrong in either direction fails system-prompt-section-presence.test.ts.
Two things are worth knowing before you edit section-registry.ts.
RUNTIME_SECTIONS ends in as const satisfies readonly RuntimeSection[] rather than containing a : readonly RuntimeSection[] annotation. The annotation typechecks and reads better, and it silently disables every check above: it widens input.key to string, so “is this a real option field” starts accepting anything. system-prompt-section-derivation.test.ts fails if the annotation comes back.
Position is the row’s position in the array. There is no separate order list to keep in step, and promptSectionOrder permutes template and runtime sections together from the same list, so a new runtime section is reorderable by a harness profile with no extra wiring.
A runtime section lands in its own entry of the returned string[], outside block 0. Block 0 is the byte-stable prefix a provider caches, and system-prompt-cached-prefix-stability.test.ts records its digest: adding a runtime section leaves that digest alone, and a change that moves text into the prefix fails there with the section named.
11) Adding a prompt (contributors)
Drop the .md under the directory that matches WHEN it fires, add its import and its row to that directory’s rows.ts, and use it through that module. That is the whole procedure, and each step is checked:
// packages/coding-agent/src/prompts/turn-control/rows.ts
import turnControlAutoContinue from "./auto-continue.md" with { type: "text" };
export const turnControlPrompts = {
"turn-control/auto-continue": {
text: turnControlAutoContinue,
purpose: "continues a turn the model ended without finishing",
},
// ...
} satisfies Record<string, PromptEntry>;
Read it back from the same module:
import { turnControlPrompts } from "../prompts/turn-control/rows";
const text = turnControlPrompts["turn-control/auto-continue"].text;
The import is the registration, so there is nothing else to remember. A file with no row is unreachable code rather than a prompt that quietly ships unlisted, and prompt-registry-coverage.test.ts fails if the directory and the rows disagree in either direction.
prompts/registry.ts aggregates all twenty-one row modules into PROMPTS, which is still the aggregate every cross-directory consumer takes, and PromptId is still the union of every id. Prefer the row module: it is the reason the rows are split at all. The registry held all 163 .md imports itself, so importing it for one string reached all 163 prompt modules, which cost the file-reading tool 167 modules for its own description. Reach for the aggregate when a module genuinely spans directories, or when the id is not known statically and you need requirePrompt.
The satisfies clause is not decoration. An annotation (: Record<string, PromptEntry>) typechecks and widens every key to string, and PromptId then accepts any string: a typo compiles and renders as the empty prompt.
Three things that suite will refuse, each because it has happened:
- Importing a
.mdoutside a registry. Registration would go back to being optional, and the registry back to being an incomplete list that looks authoritative. A relative path into another package’s prompts tree is rejected even for a file that is otherwise fine to read, because it records that package’s layout a second time. - Writing a prompts directory down twice. Consumers read
diroff the descriptor. Four of them used to type the path themselves and one had gone stale. - A row whose
purposestates nothing. The purpose is what makes the registry a list a person can read instead of a directory listing with extra steps.
If you are adding the first prompt to a package that has none, give it a src/prompts/registry.ts of its own rather than reaching into another package’s. Rows per directory are worth it once a registry is large enough that a consumer of one prompt paying for all of them matters; the other three packages hold their rows in the registry itself. A package defines its prompts; sharing the row SHAPE is what @veyyon/utils is for.
12) Settings that change the prompt
The rule: policy is a setting, not a sentence
The outer system-prompt.md scaffold holds no policy, prose, conditions, or banners. Anything that decides what the model should do belongs to a setting and a statement row. Put whole-statement presence conditions in statement-registry.ts. Put wording-level Handlebars variables inside that statement’s Markdown module.
The failure this rule exists to prevent is concrete. The delegation section used to carry a literal category list:
“…multi-file changes, refactors, new features, tests, investigations — MUST be decomposed and delegated.”
An audit is an investigation, so the prompt instructed the model to delegate audits, in every
session, whether or not an agent suited to that work existed. That policy was invisible in
/settings, unaffected by the Agents table, and only findable by reading the template. It was
not a wording problem: a hardcoded list cannot follow a setting, so it was wrong in every
session that did not happen to match it.
The check to apply when writing template text:
| Kind of text | Belongs in the template? |
|---|---|
| Structure: headings, ordering, the shape of a list | Yes |
A fact about this session ({{cwd}}, the tool names, the concurrency cap) | Yes, as a variable |
| A behavior a setting decides | No — a {{#if}} on that setting’s gate |
| A behavior nothing decides, stated as a rule the operator cannot see or change | No — make it a setting first |
For delegation this means the template never lists what is delegable. The enabled agents are
the instruction: subagentNames and hasSubagentSpecialists carry the operator’s answer, and
the template reads them. Enabling reviewer is how an operator says reviews are delegable
here, so nothing needs to say it in prose.
The gates
Some of the prompt’s text is decided by a setting. The IRC coordination clause appears only
when the session can still spawn subagents, the delegation section changes wording with
subagent.delegation, and the personality block disappears when personality is none.
Those settings are listed in one place,
packages/coding-agent/src/system-prompt-builder/gate-registry.ts. Each row records the
setting path, the template variables it decides, one line on what the model sees change, and
whether flipping it reaches a running session.
Live and frozen gates
A live gate takes effect when you change it. The settings UI rebuilds the system prompt from the registry, so the model sees the new text on its next request. These are live today:
| Setting | What changes in the prompt |
|---|---|
personality | the personality block, or nothing when set to none |
tui.renderMermaid | whether the model is told Mermaid fences render as terminal diagrams |
subagent.enabled | the whole Delegation section, which is absent when subagents are off |
subagent.delegation | whether the section requests delegation, and whether it uses MUST/ONLY wording |
subagent.batch | which call shape the delegation guidance teaches |
subagent.maxConcurrency | the concurrency limit quoted in that guidance |
subagent.maxNestedSpawnDepth | the IRC coordination clause, present only when this session can spawn |
subagent.agents | which specialists delegation prose names |
includeModelInPrompt | whether the active model is surfaced in the workstation block |
tools.format | whether tools are described inline or left to the provider’s tool list |
inlineToolDescriptors | whether descriptors live in the prompt or provider schemas for the active model |
tools.intentTracing | whether the prompt explains the intent field, and whether tool schemas carry it |
tools.intentTracing and inlineToolDescriptors also decide provider schema shape. When intent
tracing is on, every tool schema sent to the model contains an extra intent field and the prompt
explains it. Descriptor placement sends full descriptions in exactly one place. In auto mode,
Gemini receives them inline while other native tool-calling models receive them in their schemas.
The agent resolves both settings on every request, so a model switch rebuilds the prompt and updates
the schemas together.
A frozen gate is read once at session start, so changing it mid-session saves the new value and leaves the prompt as it was. The settings screen reports when a change applies on the next session. One gate remains frozen:
| Setting | Why |
|---|---|
includeWorkspaceTree | read into a session constant before the prompt builder is defined |
What a gate is worth when nobody says
buildSystemPrompt takes every gate as an optional argument, so a caller can omit all of them.
That is what the SDK does when it builds a prompt outside a session, and what tests do. The
fallbacks live in one table, OMITTED_GATE_DEFAULTS in
packages/coding-agent/src/system-prompt-builder/gate-inputs.ts, and the builder reads them from
there rather than repeating a value next to each argument.
An omitted gate means the caller has no configuration to offer, so the gate renders off or empty. That is not the same as a default session, and on four gates it is deliberately different:
| Gate | Omitted | A default session |
|---|---|---|
eagerTasks | false, no delegation ask | true, because subagent.delegation ships as preferred |
taskIrcEnabled | false, no coordination clause | true, because the recursion limit allows spawning |
subagentNames | [], prose lists no specialist | the agents this session can spawn |
taskMaxConcurrency | 0, quote no cap | 32, the shipped limit |
You want the resolved values, not the fallbacks, whenever you are showing or benchmarking a real
configuration. Call resolveGateInputs(settings, { tools, model }) and spread the result, which is
what both sdk.ts and veyyon prompt do. Building by omission is how veyyon prompt once printed
a prompt with no delegation guidance for a session that had it.
prompt-gate-inputs.test.ts renders the prompt both ways and asserts the four differences above by
value, so a fallback that starts disagreeing with its setting for no stated reason fails there.
Adding a gate
When a setting controls whether a whole statement is present, add a row to PROMPT_GATES and use that variable in the statement row’s condition. When the setting changes wording inside one statement, keep the Handlebars conditional in that statement’s Markdown file. Never add a gate to the outer system-prompt.md scaffold.
You declare the gate’s builder input in one place. GateInputs in
system-prompt-builder/gate-inputs.ts holds the field and its doc comment, and
BuildSystemPromptOptions extends Partial<GateInputs>, so a field you add there is a builder
option immediately. Do not restate the default in the doc comment. OMITTED_GATE_DEFAULTS defines what
an omitted option means.
Then pass the variable in the statement context in system-prompt.ts. This step has no type that
can prove the runtime value was supplied, because statement templates read context by name. A gate
you omit renders as off. prompt-gate-registry.test.ts and the statement gate matrix check that
declared variables reach observable statement output.
prompt-gate-registry.test.ts rejects five things, each because it has happened:
- An unclassified gate. Every
{{#if}}variable in the template must be either a registered settings gate or listed as fed by something else. A new one fails until you decide which it is. - A setting path the schema does not define. Rows carry paths as strings, so a typo would produce a gate that never fires and reads like a working row.
- A per-setting rebuild call in the controller. That hand-written list carried two of the nine gates, which is how seven settings came to change the configuration and leave the prompt describing the previous one.
- A registered gate the context never passes. The suite builds a real prompt and reads the
statementContextit rendered with, so a variable that is missing,undefined, or shadowed by a later spread fails there rather than rendering as off. - A context value pinned to a constant. Present but fixed is the same bug with a key in place, so the suite also asserts the value follows what the caller requested.
A row marked frozen-by-placement also has its claim checked against sdk.ts: the setting
really is read above the prompt builder. Move that read inside and the test fails, which is
the reminder to reclassify the gate rather than leave a stale label on one that now works.
That is how tools.intentTracing stopped being frozen. Its row said what would have to change,
in the row itself: not only moving the read, but making the tool-schema injection follow the
setting as well. Both happened, so the row now reads live, and a separate suite in
packages/agent proves the schema half by flipping the resolver between two requests to the same
agent. The prompt suite alone could not prove it, because it passes just as well on a build where
the schemas never change.
13) Statements: the prompt is a list, not a document
The system prompt is a list of statements. A statement is a fragment of prompt text with an id,
a condition, and a purpose. Its text lives in
src/system-prompt-builder/statements/<section>/<id>.md, and the row that registers it lives in
statement-registry.ts.
Why
Sections were already rows, so a section is addressable, orderable and overridable. The
conditions inside a section were {{#if}} blocks buried in prose, so they were none of those
things. Two consequences you can see in the tests: the gate suite had to run a regular expression
over system-prompt.md to find out what the prompt gates on, and the end-to-end suite could only
assert that two 76KB strings differed, because a single gated line had no name to assert on.
A statement has a name. That is what lets you refine one point of the prompt, assert that it appears under the right conditions, measure what it costs in tokens, and ablate it in an eval.
How fine is a statement
One rule sets it:
A statement is the smallest unit that can independently be present, absent, or different across sessions. If you cannot name a condition or configuration under which it would change, it is not a statement, it is part of one.
So the ROLE section’s fourteen lines are two statements, not fourteen. The role sentence and the
five engineering principles are always present together, so they are one statement. The Mermaid
bullet is a second, because renderMermaid removes it.
The rule has one addition, and the last two sections are why. A unit the prompt itself delimits
may be its own statement even when nothing varies it. DELIVERY CONTRACT is five unconditional XML
blocks (<contract>, <completeness>, <evidence-and-output>, <yielding>, <critical>) and
EXECUTION WORKFLOW is six numbered steps under markdown headings. The rule as stated would merge each
set into a single row. It should not, because those boundaries are declared by the document rather
than invented by the registry, and an eval that ablates one contract block or one workflow step needs
each to have a name.
So the check is: two adjacent always rows are a merge to make unless the second one opens a unit the
document declares, meaning its text starts with a markdown heading or an XML tag. Two adjacent
always rows of plain prose are still reported, which is the case the rule was written for.
Conditions
A row contains one of six conditions:
| Condition | Meaning | Template shape it replaces |
|---|---|---|
always | in every prompt | plain text |
when | a variable is truthy | {{#if x}} |
whenContains | a collection holds a member, such as a tool being active | {{#has tools "task"}} |
whenAll | every nested condition holds | nested {{#if}} blocks |
whenAny | any nested condition holds | {{#ifAny a b}} |
not | the nested condition does not hold | a block-level {{else}} arm |
whenAll and whenAny hold conditions rather than variable names, so they nest. The condition
algebra can therefore say “A and not B”. For example, the descriptor statement uses
allOf(when("hasTools"), not(when("toolListMode"))). Write conditions with the builders
(when, contains, allOf, anyOf, not) rather than object literals; they construct exactly the
same values and the rows stay readable.
The variable a condition names has to be either a registered settings gate (gate-registry.ts) or a
row in SESSION_FACT_VARIABLES. A typo, or a variable the builder renamed, would otherwise produce a
statement that never appears and reports nothing, so statement-registry.test.ts rejects it.
What stays in Handlebars
Only block-level conditions become separate statements. Wording-level conditionality stays inside the relevant statement module:
- {{#if label}}{{label}}: `{{name}}`{{else}}`{{name}}`{{/if}}
That is one bullet inside an {{#each}}, not two statements. Splitting it would shatter a sentence
into fragments and make the registry finer than behavior requires. The division is:
- the statement registry sets whether a statement is present,
- Handlebars inside the statement module sets the statement text.
This is why statement Markdown can still contain {{#each skills}}, {{toolRefs.task}}, and
{{#list globs join=", "}}. The outer system-prompt.md scaffold contains none of them.
The registry owns section structure
A statement file never contains a section banner. assembleSection renders the banner from the
section registry at the width banner-grammar.ts owns. A PROMPT_SECTIONS/<id>.md replacement also
contains body text only. The same assembler adds its registry banner, so shipped statements and
operator replacements cannot disagree about a section boundary.
How statements reach the model
assembleSection returns Handlebars template text, not rendered text.
assembleStatementSections creates the complete static section map. Operator overrides apply to
that map, and assembleDefaultTemplate fills the outer scaffold:
const statementSections = assembleStatementSections(data, statementOverrides);
const sectionOverrides = applySectionOverrides(files, statementSections);
assembleDefaultTemplate({ ...statementSections, ...sectionOverrides });
The scaffold is:
{{templateSections}}
The complete document is rendered once, so formatting and variable expansion are global rather
than changing with statement boundaries. assembleDefaultTemplate defines the one newline between
adjacent static sections. Statement modules own only their own final line.
Operator section overrides win because they are spread after the shipped statement map. Append mode starts from the complete statement-assembled section, then adds your body inside that region. There is no prose-bearing template fallback.
Structural invariants
The test suites enforce these contracts directly:
system-prompt.mdcontains exactly the{{templateSections}}variable and no literal prose, condition, or banner.- Every static section declared by
section-registry.tsowns at least one statement. A missing section fails module loading. - The registry supplies section order and banner bytes.
- Replacement files are body-only. A legacy file containing its own banner fails loudly.
- The gate matrix renders every statement condition through the modular assembly.
- Production
buildSystemPromptoutput proves the statement modules, operator precedence, and section ordering reach the model.
There is no frozen prose copy and no migration byte-parity fixture. Prompt behavior is tested from the one modular source.
What each rule costs, and testing one of them
Two things follow from a rule having a name, and both are the reason the migration was worth doing.
veyyon prompt --statements prints what each rule costs. The number is MARGINAL: what the prompt
would be shorter by without that rule, not the length of the rule’s text. The distinction matters
because render ends in a format pass that normalizes whitespace across statement boundaries, so
the lengths of the statement texts do not add up to the length of the section they form. Measured the
other way, the parts reconcile with the whole exactly:
section bytes = banner + sum of statement bytes + separator
The banner belongs to the section registry, and assembleDefaultTemplate defines the one newline
between adjacent static sections. prompt-inspect.test.ts asserts that the reported parts reconcile,
so a change to either convention cannot silently corrupt the cost breakdown.
veyyon prompt --statement <id> prints one rule’s rendered text. The text it prints weighs exactly
what the table charges the rule, which is asserted, so the two surfaces cannot disagree about the same
rule. A rule that is not in this prompt reports the condition that would include it and exits 0,
because a rule being off is a configuration rather than a failure.
VEYYON_EVAL_SYSTEM_PROMPT_STATEMENTS changes one rule. It is a JSON object of statement id to
replacement text, or to null to remove the rule entirely:
VEYYON_EVAL_SYSTEM_PROMPT_STATEMENTS='{"tool-policy/delegation-gates": null}'
Same instrument as VEYYON_EVAL_SYSTEM_PROMPT_SECTIONS, one level finer, and deliberately the same
shape: environment variable only, no config key, no CLI flag. A config-reachable prompt override could
silently contaminate a production run, and a contaminated eval reports a number that looks valid.
null and "" are different operations, so pick deliberately. null ablates: the row and the
separation it contains both leave the prompt, because a statement’s text includes its own separation.
"" keeps the row present and empty, so the separation stays and only the words go. Use the first to
ask whether a rule is worth having, the second to ask whether it needs saying at all.
Every way an override could do nothing is an error rather than a no-op: an unknown statement id, a
value that is neither a string nor null, malformed JSON. An arm that quietly did nothing would
report the shipped prompt’s score as the arm’s score, which is a false result with no signal that
anything went wrong.
An override targeting a rule whose condition is false is rejected. A statement override also cannot target a section replaced wholesale by a section override, because the section replacement would silently discard the statement arm. These conflicts fail before the prompt is assembled.
All six static sections come from statements. STATEMENT_SECTIONS is derived from the sections the
registry declares, and the module will not load if any one has no statements. The zero-prose
system-prompt.md scaffold cannot supply fallback instructions, so losing a section is a loud
assembly failure rather than a silent reversion.
Bounded reads and search
Three tools give the agent controlled access to your files: read, search, and
write. They are always available; there is no experimental_tools or backends.toml gate
to turn them on.
The point of these tools is bounds. An unbounded cat, find, or grep -r in the shell
can dump enough text to fill the whole context window. These tools apply line, byte, and
result caps instead, and they surface truncation rather than dropping output silently. This
page documents each tool’s parameters and the limits it enforces. The implementations live
under packages/coding-agent/src/tools/{read,search,write}.ts.
The read tool (tools/read.ts)
read takes a single path string (no separate offset/limit arguments) and bounds every read
to a budget:
- One parameter, inline selectors.
read {path}, wherepathcan carry a line-range selector appended after a colon:src/foo.ts:50-200(inclusive range),src/foo.ts:50/:50-(from line 50 on),src/foo.ts:50+150(150 lines from line 50), orsrc/foo.ts:5-16,960-973(multiple ranges in one call).:rawreads verbatim with no anchors or line prefixes. - Dual budget, whichever is hit first: a line cap and a byte cap. The line cap is
read.defaultLimit(300) when the call names no line count, the requested count when it does, andDEFAULT_MAX_LINES(3000) at the ceiling. The byte cap istools.artifactSpillThreshold(50 KB), the same budget every other tool result carries; a call that names a line count raises it to hold those lines, at about 512 bytes a line. So a file that is short in lines but huge in bytes (minified JS, a data blob) is bounded by bytes, and a file of many short lines by lines.readis bounded rather than spilled to an artifact: a paged window with a continuation selector is more use than a truncated one with a link. - Structural summaries for parseable code. A read with no selector on a parseable source file
returns declarations with bodies elided (
…), and the footer states the recovery selector so the model re-issues only the ranges it actually needs instead of re-reading the whole file. The summary takes the same byte budget as a file window, and lines wider thantools.outputMaxColumnsare clipped, so a declaration-dense file (generated protobuf bindings, a large.d.ts) returns a bounded window with the line that continues it rather than the whole projection. - Truncation is explicit. A summary footer or a
[Showing lines …]-style notice states the continuation selector. - Beyond plain text files: the same tool also reads directories (depth-limited listing), archives
(
.tar,.tar.gz,.zip, viaarchive.zip:path/inside), SQLite databases (file.db:table, with pagination andwhere/orderfilters), PDF/Word/PowerPoint/Excel/EPUB (extracted text), Jupyter notebooks (editable cell text), images, URLs (reader-mode by default), and internal URI schemes (memory://,skill://,artifact://,mcp://,ssh://, and others).
Text reading is intentionally separate from image inspection. By default, read decodes image
files (PNG, JPEG, GIF, WEBP) inline for direct visual analysis. When inspect_image.enabled is set,
read returns image metadata instead and the model inspects the image by calling inspect_image
with a question.
@path mentions (utils/file-mentions.ts)
A @path token in a prompt auto-reads the file or lists the directory it names, and the result is
bounded by tools.artifactSpillThreshold, the same budget a tool result carries, because the
mention stays in the transcript and is billed on every later request. A capped mention states the
lines it showed and the selector that pages the rest. A file over 5 MB, a binary file, or an image
over 25 MB is not read: the message carries the path and the reason.
tools.artifactSpillThreshold bounds every model-visible result. read applies it directly to a
file window, a structural summary, a directory listing, an archive listing, a notebook or converted
document, a URL body, a PDF image-member list and an agent://<id>/<field> extraction, and states
what it carried and how to reach the rest. An extraction takes no line selector, so it is cut by
bytes and the notice names its full size and the URL that pages it. A selector-free structural
summary also stops at read.defaultLimit lines, the bound a selector-free file window already
follows, and the notice names which of the two stopped it. Every other tool passes the shared spill
layer: output over the threshold is written to an artifact and the result keeps a head and tail
window no larger than the threshold, sized by tools.artifactHeadBytes and
tools.artifactTailBytes in the ratio they name, plus the artifact:// id that reads the full text
back.
A directory read that names no depth lists the top level: every entry, up to 100, with each
subdirectory’s direct-child count beside it, and a footer naming depth: 2 for the recursive
listing. Orientation is what a selector-free listing is for, and the recursive one costs 8,163
tokens for packages/coding-agent/src against 962 for its top level. A directory wider than 100
entries states how many it held back and names depth: 1 for the flat listing of all of them.
depth and limit are honored in full when named.
The search tool (tools/search.ts)
Workspace discovery and searching are unified in the search tool, covering file path lookup,
text/regex search, and structural code search through one canonical model-facing interface. It
takes two required ordered fields followed by type-specific options:
type(ordered first): representation to match:"files": match paths and repository layout."text": match syntax-irrelevant text or regex content."structure": match code syntax and structural relationships.
input(ordered second): what to match:- for
"files": a path, directory, or glob pattern (e.g."src/**/*.ts"). - for
"text": a literal or regular expression pattern (e.g."TODO|FIXME"). - for
"structure": one structural code pattern (e.g."console.log($$$)").
- for
Type-specific options and validation
Options are strictly validated per type; cross-type fields are rejected with an actionable error:
type: "files"acceptshidden,gitignore, andlimit:hidden(defaulttrue) includes dotfiles.gitignore(defaulttrue) respects.gitignorerules; setfalseto search ignored paths.limit(default200, max200) bounds returned paths; output is sorted bymtimedescending and grouped under# <dir>/directory headers.
type: "text"acceptspath,case,paths,gitignore, andskip:pathscopes the search (file, directory, glob, internal URL likeveyyon://, or a semicolon-delimited list).ssh://scopes are supported here. Pass the narrowest known scope; omit it only when the workspace root (".") is intended. Line-range selectors (e.g.:50-100) on a single file target constrain matches.case(defaulttrue) toggles case sensitivity.gitignore(defaulttrue) respects.gitignore.pathsreturns the matching file paths with per-file match counts in place of match lines, the shaperg -lproduces.skippages past already-returned files; results are paginated at20files per call (DEFAULT_FILE_LIMIT) with an internal cap of2000matches. Context lines around matches are governed bysearch.contextBefore(default1) andsearch.contextAfter(default1). A single-file scope returns at most200matches (SINGLE_FILE_MATCHES) and a multi-file scope at most20per file (MULTI_FILE_PER_FILE_MATCHES). A file whose match list was clipped is named as such, and only for a file the current page displays;skippages files rather than matches, so passing it reaches nothing past a per-file cap. The2000-match ceiling is the one limit that leaves files unopened, so only it marks the file total a floor.
type: "structure"acceptspathandskip:pathscopes the search (file, directory, glob, local or materialized internal URL, or semicolon-delimited list).ssh://is not supported; inspect remote code withreadbefore structural matching.skipspecifies match offset for pagination (default limit50matches).- Metavariable syntax supports
$NAME(single node),$_(anonymous node),$$$NAME(multi-node sequence), and$$$(anonymous sequence). Each match lists its bindings on ameta:line; a value over 60 bytes (META_VALUE_MAX_BYTES) or containing a newline renders asKEY=…, because the binding is a range inside the match the result has already printed line by line. - A capped result states how many matches were found and returned, and names the
skipvalue that continues them.limitis a file-search field and a structure search rejects it.
Unified result contract and settings
Results return formatted text plus structured details { type, result, meta } corresponding to the search type (FileSearchDetails, TextSearchDetails, or StructureSearchDetails). meta carries the limit and truncation record the output layer reads to append the notice and to skip re-spilling an already-spilled result.
Broad grouped multi-file text searches use progressive disclosure when the full formatted match set exceeds the session’s discovery budget (scaled from an 8 KiB search-specific ceiling through the turn curve to ~2 KiB at turn 0). The full pre-disclosure output is saved to an artifact before compacting. The inline result emits up to two representative matches per file, total match and file counts, warnings, and an artifact://<id> recovery footer. Explicit single-file searches and line-range queries keep detailed output without compacting. Only the visible representative lines emitted with snapshot tags are recorded as seen for anchored editing; un-emitted matches remain unseen. If artifact storage is unavailable, broad searches fall back to generic turn-scaled head truncation.
The tool is part of the default inventory. Text matching uses two settings:
search.contextBefore: number, default1(lines of context before each text match).search.contextAfter: number, default1(lines of context after each text match).
The write tool (tools/write.ts)
read and search are the read side; write {path, content} creates or replaces a whole file. It
shares infrastructure with the edit engine rather than touching the filesystem directly:
- Shared verified pipeline.
write.tsimports the same file-snapshot store and LF-normalization helpers as the edit path (../edit/file-snapshot-store,../edit/normalize) and formats hashline headers via@veyyon/hashline, so writes inherit LSP diagnostics writethrough and diff/verification behavior rather than bypassing it. - Exclusive concurrency. The tool declares
concurrency: "exclusive", so nothing else can create or change the target file mid-call. - Steers to
editfor surgery. The tool description tells the model to prefereditfor a surgical change to an existing file, keepingwritefrom becoming a “re-emit the whole file” habit that burns tokens.
Sanitizing exec output for the model
Bash/exec tool output is sanitized before it reaches the model, via sanitizeText()
(packages/utils/src/sanitize-text.ts), used from session/streaming-output.ts and the interactive PTY
capture path (tools/bash-interactive.ts):
- ANSI stripping is Bun-native, not a hand-rolled parser.
sanitizeText()calls Bun’s built-inBun.stripANSI()when an ESC byte is present, then strips C0/C1 control bytes and DEL with a single regex pass. The function is a TypeScript replacement for a former Rust native (crates/veyyon-natives/src/text.rs::sanitize_text, noted in the current source comment), there is no live Rust ECMA-48 grammar walker in this path today. - Keep
\nand\t, drop the rest. The control regex covers C0 (excluding tab/newline),\r, DEL, and the C1 range;\nand\tare the two explicit exclusions. - Model-facing only. Sanitizing happens on the text that becomes tool output for the model. The TUI renders exec output from its own delta stream and keeps its colors, so the operator’s view is untouched.
- Zero-cost when clean. Well-formed input with no control/ANSI bytes returns the original string
reference after one regex probe; only output that actually carries escapes pays for
Bun.stripANSI().
Tool text per request
Every request carries the name, description and JSON schema of every active tool. At the defaults
that is 17 tools and about 14,000 tokens, paid on each request rather than once per session. The
largest entries are edit, eval, read, launch and bash.
tools.discoveryMode: "all" keeps the seven essential tools (read, bash, launch, edit,
write, search, eval) plus goal and resolve, and hides ast_edit, debug, ssh, task,
job, todo, web_search and set_cwd behind the discovery search tool, which removes about
4,800 tokens from each request. A hidden tool costs a discovery round trip on the turn that first
needs it, so the setting trades a fixed per-request cost for an occasional one.
tools.essentialOverride sets which tools stay visible.
A discovery search activates every match scoring at least half the best one. A weaker match is
returned in also_matched, and a query naming it activates it. Rank order alone activated the whole
tail: “keep track of what is left to do” activated todo, set_cwd, task and web_search, and
an activated tool’s schema is carried by every later request of the session.
Discovery ranks a tool on its full description and its one-line summary together. Ranking on the
summary alone left 96 to 99 percent of each tool’s text out of the corpus, so launch scored zero
for “tail the output of a server” and eval scored zero for “evaluate javascript”. A compound word
is indexed whole and in parts, so sqlite reaches SQLite and java script reaches JavaScript.
Why these are grouped with context
A read that bounds and a search that bounds its output are both about keeping the working context small and relevant. Long trajectories degrade when context fills with raw file dumps; these tools plus compaction & project memory are how a long task stays coherent.
Context files
Context files are Markdown instruction files that veyyon discovers automatically before a session starts and injects into the agent’s project context. Use them for repository conventions, architecture notes, test and review expectations, and instructions that should travel with a user account or a project.
Matching files (AGENTS.md, CLAUDE.md, GEMINI.md, and related) are discovered and injected into the opening session context when discovery is enabled.
How context files relate to other concepts
Four similarly named things behave differently. Keep them straight:
- Context files are read as plain Markdown and shown to the agent inside a
<context>block. They are advisory background that stays in the session’s opening context. - Sticky rules come from a top-level
RULES.md. They are converted into an always-apply rule that is re-attached near the current turn, so they keep their hold even after the visible conversation grows. See “Sticky rules vs normal context” below. - Discovery providers are the config-source adapters (
native,claude,codex,gemini,opencode,github,agents,agents-md) that record where each tool keeps its files. The same provider that contributes context files may also contribute MCP servers, slash commands, skills, hooks, tools, prompts, and settings. - Model providers are inference backends such as
anthropic,openai,google,groq,ollama, andopenrouter. They have nothing to do with context files except that both kinds of id share the onedisabledProviderslist: see “Disabling discovery providers” below and Providers.
Authoring skills and rule files (as opposed to the sticky RULES.md) is covered in Skills. Use AGENTS.md for additive instructions, PROMPT_SECTIONS/ for persistent section changes, and the two CLI flags for one-run prompt replacement or appending. See System prompt customization.
Native .veyyon files
The native provider is the recommended format for new projects. It reads from your user agent directory and from .veyyon/ directories inside a project, and it has the highest discovery priority, so its files win over every other convention at the same scope.
| File | Scope | Behavior |
|---|---|---|
~/.veyyon/AGENTS.md | Global User | Global cross-profile context for every session across all profiles. |
~/.veyyon/profiles/<profile>/... | Profile User | Active profile context. Scanned in descending priority order (first match wins; exactly 1 file loaded per profile): 1. ~/.veyyon/profiles/<profile>/agent/AGENTS.md (Highest)2. ~/.veyyon/profiles/<profile>/AGENTS.md3. ~/.veyyon/profiles/<profile>/agent/agent.md4. ~/.veyyon/profiles/<profile>/agent.md (Lowest) |
<ancestor>/.veyyon/AGENTS.md | Project | Project context. veyyon walks upward from the current directory to the repository root and every ancestor contributes at most one file. The nearest non-empty .veyyon/ directory supplies that ancestor’s file from its AGENTS.md; other ancestors fall back to a bare AGENTS.md, then a bare CLAUDE.md. See Load order and shadowing for the full per-directory order. |
~/.veyyon/profiles/<profile>/agent/RULES.md | User | User-level sticky rule content. Loaded as an always-apply rule, not as a context file. |
Two details matter:
- Walk-up to the repository root. Discovery starts in the current working directory and climbs through each ancestor up to the repository root. The nearest non-empty
.veyyon/directory claims its own level with itsAGENTS.md; every other level contributes a bareAGENTS.md, falling back to a bareCLAUDE.mdwhen noAGENTS.mdhas content there. - The
.veyyon/directory must be non-empty. An empty.veyyon/directory is skipped during the walk-up, so the search continues to the next ancestor. An emptyAGENTS.mdfile contributes nothing and shadows nothing.
~/.veyyon/profiles/default/agent is the user base, and it is profile-aware: under a named profile (--profile <name> / VEYYON_PROFILE) the base becomes ~/.veyyon/profiles/<name>/agent, so each profile contains its own AGENTS.md and RULES.md. Non-native user files (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, …) are profile-independent and still discovered under every profile. If VEYYON_CODING_AGENT_DIR is set under the default profile, it relocates the base outright, so the user files become $VEYYON_CODING_AGENT_DIR/AGENTS.md and $VEYYON_CODING_AGENT_DIR/RULES.md; under a named profile the override is ignored.
Monorepo example
repo/
.veyyon/
AGENTS.md
packages/api/
.veyyon/
AGENTS.md
Starting a session in repo/packages/api:
- The
.veyyon/context file isrepo/packages/api/.veyyon/AGENTS.md(the nearest non-empty.veyyon/directory).repo/.veyyon/AGENTS.mdis not also included, though a barerepo/AGENTS.mdbeside it would be, at its own depth.
Put broad, durable project background in AGENTS.md. Reserve RULES.md for short, hard requirements that must stay visible across long conversations; it is a user-level file, so a repository cannot ship one.
Other supported context conventions
veyyon also discovers the context and rule files of other agent tools so existing projects keep working without migration.
| Provider id | Convention path | Scope | Notes |
|---|---|---|---|
native | .veyyon/AGENTS.md | User + project | Recommended veyyon format. User file at ~/.veyyon/profiles/<profile>/agent/AGENTS.md; project files are one per ancestor directory from the repo root down to the cwd, resolved per directory as described in Load order and shadowing. |
claude | .claude/CLAUDE.md | User + project | User file ~/.claude/CLAUDE.md; project file <cwd>/.claude/CLAUDE.md only (no ancestor walk-up). |
codex | .codex/AGENTS.md | User | User file ~/.codex/AGENTS.md only. Project-level standalone AGENTS.md files load through the native provider’s ancestor walk-up, not from <cwd>/.codex/AGENTS.md. |
gemini | .gemini/GEMINI.md | User | User file ~/.gemini/GEMINI.md only. |
opencode | .config/opencode/AGENTS.md | User | User file ~/.config/opencode/AGENTS.md only. |
github | .github/copilot-instructions.md | User | User-global ~/.copilot/copilot-instructions.md (relocate with COPILOT_HOME) and an AGENTS.md from each COPILOT_CUSTOM_INSTRUCTIONS_DIRS entry. A repository’s own .github/copilot-instructions.md is not read. |
agents | .agent/AGENTS.md, .agents/AGENTS.md | User | User files from ~/.agent/ and ~/.agents/ only; there is no project scope. |
agents-md | AGENTS.md | Project | Standalone (non-config-directory) AGENTS.md files, discovered by walking up from the current directory to the repository root (or home when no repo root is known). Files whose parent directory name starts with . are ignored, those belong to a config-directory provider instead. |
github | <dir>/.github/instructions/**/*.instructions.md | User rules | GitHub Copilot / VS Code instruction files under each COPILOT_CUSTOM_INSTRUCTIONS_DIRS entry become rules. applyTo: '*' or applyTo: '**' is injected as always-apply context; other applyTo globs are listed in the rulebook with description and are readable as rule://<name>. A repository’s own .github/instructions/ is not read. |
Providers marked “(no ancestor walk-up)” only look in the current working directory’s config directory. If you need ancestor walk-up behavior, prefer the native .veyyon/AGENTS.md format or a standalone AGENTS.md (the agents-md provider), or launch veyyon from the directory that holds the config directory.
Load order and shadowing
When two providers describe the same scope, the higher-priority provider wins. Provider priorities:
| Priority | Provider id |
|---|---|
| 100 | native |
| 80 | claude |
| 70 | agents, codex |
| 60 | gemini |
| 55 | opencode |
| 30 | github |
| 10 | agents-md |
Discovered files are then deduplicated by scope:
- One user context file is kept across all providers. Because
nativehas the highest priority,~/.veyyon/profiles/<profile>/agent/AGENTS.mdshadows every other user-level context file. - One project context file per directory depth. Depth is measured from the current directory: the cwd is depth 0, its parent depth 1, and so on. Config subdirectories of an ancestor (
.claude/,.github/,.gemini/, …) count as the same depth as that ancestor. - Within one directory,
nativepicks a single file before any shadowing happens. The order is.veyyon/AGENTS.md(only from the nearest non-empty.veyyon/directory), then a bareAGENTS.md, then a bareCLAUDE.md. The first one that has content wins and the rest of that directory’s candidates are never read, so aCLAUDE.mdbeside anAGENTS.mdis not loaded, not appended, and not deduplicated later.CLAUDE.mdis last becauseAGENTS.mdis the tool-neutral convention: a project containing both is nearly always stating the same rules twice, and a staleCLAUDE.mdmust not contradict a maintainedAGENTS.md. A candidate that is empty or unreadable contributes nothing and therefore shadows nothing, so the next one down gets its turn. - The pick is per directory, not per project. A repo root with only
AGENTS.mdand a package directory with onlyCLAUDE.mdboth load, each at its own depth. - At the same depth, the higher-priority provider shadows the rest.
- Across depths, multiple files survive. In a monorepo, an ancestor
AGENTS.mdand a package-level one are different depths and both load. - Contained files are collapsed. If one surviving file’s whole content already appears inside another’s, only one copy is kept, and the copy that survives is the one from the more authoritative scope (see below). Two files from the same scope fall back to position, so between a repo-root file and a package file with identical text the package one is kept.
After deduplication, project files are sorted so farther ancestors appear first and files closer to the cwd appear last. Both are project scope, so this is one project directory refining another, not a project file outranking a broader scope.
Scope authority: your own configuration is last and wins
Provider priority and depth decide which files survive. A separate axis sets where each survivor is rendered, and therefore which one wins an outright conflict. These are two different orders and it is easy to read one as the other:
- Resolution order is the order the three scopes are read: global, then profile, then project.
- Authority order is the order they are rendered, least authoritative first: the project group (farther ancestors first, closest to the cwd last), then the profile file, then the cross-profile global
~/.veyyon/AGENTS.mdlast of all.
Your live instruction in the conversation beats all of them. Below that, the ladder runs broadest to narrowest: your own ~/.veyyon/AGENTS.md, then the active profile’s file, then the project’s files lowest. A narrower file may add detail the broader ones do not cover, and the agent follows it there, but it may not contradict, loosen, or forbid what a broader file allows.
That direction is a safety boundary, not a style choice. A project file is content checked into a repository you may not have written, so letting one outrank your own configuration would let any repository you clone rewrite the rules you set for yourself. Within the project group the file closest to your working directory is still the most specific one, because both files are project scope and neither outranks the other on the ladder.
Worked shadowing example
repo/
AGENTS.md
packages/api/
AGENTS.md
.claude/CLAUDE.md
Starting in repo/packages/api:
- Both bare
AGENTS.mdfiles load throughnative(priority 100):repo/AGENTS.mdat depth 2 andrepo/packages/api/AGENTS.mdat depth 0. repo/packages/api/.claude/CLAUDE.md(claude, priority 80) also resolves to depth 0 and is shadowed there by the higher-priority native file.- The kept files are ordered root-first, package-last, so
packages/api’s file is the more specific one within the project group. - If you add
repo/packages/api/.veyyon/AGENTS.md, it is the nearest non-empty.veyyon/AGENTS.mdand loads as the project context file at its depth;repo/.veyyon/AGENTS.mdis not also included.
Injection behavior
Discovered context files are injected into the opening project prompt as a single <context> block, one <file> element per surviving file, least authoritative first, so the project files come before the profile file and the global file comes last:
The user's instructions in this conversation have ABSOLUTE authority. ...
<context>
The user-authored context files below rank from BROADEST to NARROWEST, and a narrower file NEVER overrides a broader one:
1. The user's OWN configuration, from their home config directory. ...
2. The active profile's configuration.
3. The PROJECT's files, from the repository you are working in. LOWEST authority of the three.
...
<file path="/abs/path/to/repo/AGENTS.md">
...root content...
</file>
<file path="/abs/path/to/repo/packages/api/AGENTS.md">
...package content...
</file>
<file path="/home/you/.veyyon/AGENTS.md">
...your own standing rules...
</file>
Precedence again, because you have just read these files in ascending order of authority and the
one you read FIRST is the narrowest, not the strongest: ...
</context>
The agent sees each file’s absolute path and its fully expanded Markdown content (with @ imports already resolved, see below). When discovery is enabled, matching context files are injected at session start.
A sentence stating that your live instruction in the conversation has absolute authority renders in every session, whether or not any context file loaded, because a rule or a memory can tell the agent to reject just as a file can. The scope ladder above renders only when at least one context file loaded, since there is nothing to rank otherwise. Below your live instruction, the surviving context files win over conflicting generic Veyyon workflow defaults, retrieved material, and historical summaries; among themselves they rank by the scope ladder, and a project file never overrides your own configuration.
Deeper-directory AGENTS.md files that were not auto-loaded (for example, ones below the current directory) are surfaced separately in a <dir-context> block that lists their paths and tells the agent to read them before editing those directories. Those files are pointers, not full injected content.
@ imports
Inside any context file, an @path token expands inline to the referenced file’s content before injection:
# Project notes
Read @docs/architecture.md before changing storage code.
Shared release steps live in @../RELEASE.md and personal aliases in @~/.notes/aliases.md.
The exact rules:
- Relative paths resolve from the importing file’s own directory, not the session’s working directory.
~/and~resolve from the user’s home directory; absolute paths are used as-is.- Tokens inside fenced code blocks and inline code spans are left untouched: useful when you want to write about an
@tokenwithout expanding it. [email protected]:org/repo.gitand[email protected]-style tokens are not treated as imports. A token only counts when the@sits at the start of a line or after a space or tab.- Trailing sentence punctuation is trimmed off the path (
. , ; : ! ? ) ] } " '), so@notes/setup.md.importsnotes/setup.md. - Imports recurse up to five hops. An imported file may itself contain
@imports, up to a total depth of five. - Cycles are skipped. A file already pulled into the current expansion tree is not re-expanded, so mutual imports terminate cleanly.
- A missing or unreadable target leaves the original
@tokentext in place rather than erroring.
Sticky rules vs normal context
Use a normal context file (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, …) for the bulk of your guidance: repository overview, code style, build and test commands, review expectations, and local conventions. These load into the opening <context> block.
Use a top-level RULES.md for the handful of hard requirements that must stay active even after a long conversation has pushed the opening context far up the transcript:
# ~/.veyyon/profiles/<profile>/agent/RULES.md
Never commit or push unless the user explicitly asks.
Do not edit generated files.
RULES.md is special:
- It is read only at the user location
~/.veyyon/profiles/<profile>/agent/RULES.md. ARULES.mdanywhere else, including inside a repository, is not a context-file convention and is ignored. - It is loaded as an always-apply rule, not as a context file, so it is re-attached near the current turn and keeps its hold across long sessions.
- It is always sticky: frontmatter cannot make it non-sticky. If you want conditional or opt-in behavior, write a normal rule file instead (see Skills).
Keep RULES.md short. Long background belongs in AGENTS.md, where it costs context budget only once.
Disabling discovery providers
Turn a provider off with the disabledProviders setting in ~/.veyyon/profiles/<profile>/agent/config.yml or a --config overlay:
# ~/.veyyon/profiles/default/agent/config.yml
disabledProviders:
- claude
- github
disabledProviders is a whole-provider switch with one shared id namespace, used by two unrelated subsystems:
| Id kind | Examples | Effect when listed |
|---|---|---|
| Discovery provider ids | native, claude, codex, gemini, opencode, github, agents, agents-md | The entire config source is removed, not just its context files, but also any MCP servers, slash commands, skills, hooks, tools, prompts, and settings it would have contributed. |
| Model provider ids | anthropic, openai, google, groq, ollama, openrouter | The model backend is removed from selection even when its credentials are present. See Providers. |
Ids are exact and the two namespaces do not collide by accident: google disables the Google model backend, while gemini disables the Gemini CLI discovery files. Disabling a discovery provider is heavier than it looks, disabling claude, for instance, also drops Claude-discovered MCP servers, commands, skills, hooks, tools, and settings, not only CLAUDE.md.
Only enabledModels and disabledProviders support path-scoped entries, so you can vary provider availability per subtree:
disabledProviders:
- github # disabled everywhere
- path: ~/work/legacy-claude
providers:
- claude # disabled only under this directory
A scoped entry applies when the cwd equals the configured path or sits beneath it; ~ expands to home. Bare string entries apply everywhere.
Remember that higher-precedence settings layers replace array settings rather than appending to them. If your profile config disables claude but a --config overlay sets disabledProviders: [github], then in that process Claude discovery is re-enabled and only GitHub is disabled. See Settings for the full layer precedence, merge rules, and path-scoped array details.
Troubleshooting
A file is not loaded
- Native project context must live at
.veyyon/AGENTS.md, and the.veyyon/directory must be non-empty; an empty.veyyon/is skipped and the walk-up continues to the next ancestor. - A standalone
AGENTS.mdorCLAUDE.mdat any ancestor is loaded bynativeitself;agents-mdcontributes only whennativeis disabled. ACLAUDE.mdis skipped when the same directory has a usableAGENTS.mdor.veyyon/AGENTS.md; that is deliberate, see Load order and shadowing. .claude/CLAUDE.mdis read only from the current working directory, not from every ancestor..gemini/GEMINI.mdand.github/copilot-instructions.mdare user-level only; a repository’s copies are not read.~/.codex/AGENTS.mdand~/.config/opencode/AGENTS.mdare user-level only and have no project equivalent.- Empty files contribute nothing for the native and standalone providers.
- A disabled discovery provider contributes nothing: check
disabledProvidersacross your profile and--configlayers.
The wrong file wins
At one user scope or project depth, the higher-priority provider shadows the others (native > claude > agents/codex > gemini > opencode > github > agents-md). To force deterministic behavior, move your guidance into .veyyon/AGENTS.md (native always wins) or disable the competing discovery provider.
User context disappeared
Only one user-level context file survives, and ~/.veyyon/profiles/<profile>/agent/AGENTS.md has the highest priority. If it exists, it shadows user-level ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md, ~/.config/opencode/AGENTS.md, ~/.copilot/copilot-instructions.md, and ~/.agent/~/.agents files. Consolidate user guidance into the native file or remove the native one if you prefer another tool’s file. A profile without one falls through to the next-priority user file (typically ~/.claude/CLAUDE.md).
A RULES.md file is ignored
Only one native RULES.md location is sticky: ~/.veyyon/profiles/<profile>/agent/RULES.md. A RULES.md in any other directory, including a repository’s .veyyon/, is not a recognized convention and will not be loaded.
An @ import did not expand
Confirm the target exists relative to the importing file (not the cwd). Imports inside fenced code blocks or inline code spans are intentionally left literal, git@ and email-looking tokens are never imported, cycles are skipped, expansion stops after five hops, and a missing target leaves the original @path text unchanged.
Goal state and long sessions
On a long task, the model can drift. The objective was stated an hour ago, and now it is buried under a thousand messages the model reads only the tail of. Goal mode fixes this. It pins a structured objective to the session and injects it separately from the raw conversation tail, so the goal stays in view no matter how long the transcript grows. It pairs with compaction, which handles the history behind it.
Goal card (session-backed)
id:
objective:
status: # active | paused | budget-limited | complete | dropped
token_budget: # optional
tokens_used:
time_used_seconds:
turns_completed: # agent turns accounted to this goal
created_at / updated_at:
The harness persists this on the session. Updates come from /goal commands and the goal tool (create, get, complete, resume, drop). User objective text is escaped before prompt injection.
Token accounting includes input, output, and cache-write deltas used for provider billing. turns_completed counts each agent turn that ran under the goal, so it advances only while the goal is active. Goal budgets are disabled by default. Only the interactive Settings UI can toggle goal.modelBudgetsEnabled; the goal tool and slash-command surfaces cannot change it.
Status indicator
While a goal is set, the mode segment in the status line reads Goal with a live token count. When goal.modelBudgetsEnabled is on and you set a token budget, it shows used/budget and a percent, for example 20K/50K 40%. Once the goal has burned 90% or more of its budget, the segment turns to the warning color so you see the ceiling approaching before the goal hits budget-limited. With the setting off, persisted budgets are inert and the segment shows only tokens used.
The goal icon animates through the theme spinner frames while the agent is streaming under the goal, and holds steady when the goal is paused or idle. The animation is driven by active processing time, so it moves only while work is happening.
The goal.statusInFooter setting no longer controls whether the token count appears (it always does). It now controls verbosity: turn it on to also render a compact ▰▱ progress bar next to the numbers.
To see the full goal card, press the down arrow while the composer is empty. This opens the goal detail menu (the same menu /goal opens): objective, status, tokens used, completed turns, time spent, and the pause, resume, and drop actions. When goal.modelBudgetsEnabled is on, the card also shows budget progress and the adjust-budget action. The down arrow only opens this while a goal is active or paused, so it never interferes with normal editing.
Context assembly
Each turn combines system rules, goal injection (when active), active instructions, recent transcript, compaction prefix, and other session context. Compaction settings: Compaction and project memory. Operator commands: Plan mode and goals.
What goal mode provides
- Objective visible across turns without rereading the full transcript
- Idle continuation toward the objective when
goal.continuationModesallows - Optional token budgets when
goal.modelBudgetsEnabledis on, plus pause/complete/drop lifecycle
Compaction and project memory
A long session eventually fills the context window. The simple fix, dropping the oldest messages, loses the decisions and constraints the model still needs. Compaction is the better fix: instead of truncating old history, it compresses it into a summary and keeps working. At any moment a long session holds three records: the goal (when enabled), the recent transcript verbatim, and the compacted history behind it.
Context compaction
Primary compaction knobs (settings → Models → Compaction, or config.yml):
-
Threshold (
compaction.threshold): when auto-compaction runs. The unit is part of the value, so one setting covers all three ways you might want to say it:auto(the default) triggers at the model’s context window minus the reserve, so it adapts to whatever model you are on.85%is a percent of the current model’s window, so the trigger moves with the model.170000is an absolute token amount and triggers at the same point on every model. When the amount is larger than the current model’s window it is honored up to one token below the window and you get a warning (once per model context window, so switching to a smaller-window model warns again).
You can also compact on demand with
/compact. -
Type (
compaction.strategy):summary, the sole strategy. It rewrites old history into an in-place LLM summary on the current branch. -
Model (
compaction.model): the models that perform LLM compaction, tried in order. Unset uses your interactive model. See Fallback models below and Models, roles, and profiles.
/compact <focus> steers a run with an “Additional focus:” directive. The most
recent user, assistant, and tool messages stay verbatim up to
compaction.keepRecentTokens (default 10,000 tokens).
Use /handoff <focus> when you explicitly want a new session. Handoff is not a
compaction strategy, and automatic maintenance never selects it.
Compaction and handoff both write a machine-owned continuity record separate from generated prose. It preserves the active objective, the original user contract, goal and todo state, pending blockers, changed paths, verification evidence, and checkpoint state. Handoff writes that record into the replacement session before the next turn. Reopening either session restores exact state instead of relying on generated prose to repeat every field.
Stored legacy strategy names such as handoff, snap, soft, and remote
migrate to summary. A legacy off value also disables compaction.
Fallback models
compaction.model is an ordered list, not one model:
compaction:
model: anthropic/claude-opus-4-1,anthropic/claude-sonnet-4-5,anthropic/claude-haiku-4-5
Compaction tries the first entry. If you are not signed in to it, or its context window cannot
hold the history being summarized, veyyon moves on to the second, then the third. A single model
is still written the way you would expect (model: anthropic/claude-sonnet-4-5). In /settings,
the compaction model row is the same list: add a fallback, and press Enter on any entry to move it
up.
Falling back is never quiet. When compaction runs on anything other than your first choice, you get a warning in the session stating both models and the reason:
Compacted with anthropic/haiku-4-5. anthropic/opus-4-1 was skipped: it is not authenticated.
You see that once per distinct reason, not once per compaction.
compaction.modelFallbackStrategy sets what happens after your list runs out:
auto(the default) stays on models you named: your main model, the same-provider compaction sibling its catalog row recommends, then each of your model roles.any-modelkeeps going past those to the largest context window you have credentials for, whichever provider that is. Compaction almost never fails, at the cost of summarizing on a provider you did not choose for this session and being billed for it there.configured-onlystops at the models you listed. Compaction fails with the reason instead, which is what you want when the summary quality matters more than the session continuing.
With compaction.model unset, configured-only means your interactive model and nothing else.
Compaction fires unattended, so any-model is the one setting here that can spend money on an
account you were not using: a session on one provider can summarize on another provider’s key and
report that provider’s billing error as a compaction failure. That is why it is not the default.
Shake and duplicate elision
Shake is a lighter reducer than compaction. Instead of summarizing history, it drops heavy
content out of the live context and leaves a short placeholder in its place. Whole tool
results and large fenced or XML blocks are replaced with a marker such as
[shaken ~1200 tokens; recover: artifact://42 (region 3)]. The full text is saved as a
session artifact first, so you can always read it back with read artifact://42. Nothing is
lost, it just stops being resent on every turn. Run it on demand with /shake.
Shake also removes redundancy. When you read the same unchanged file twice, or run the same
command twice and get the same output, every copy but the newest contains no new information.
Shake finds each earlier tool result whose tool, arguments, and output exactly match a later
one, and elides the earlier copies through the same artifact path. The newest copy stays in
place. This runs even for recent results that the size-based pass would otherwise keep, because
a duplicate is redundant however recent it is. Results from a protected tool (such as skill),
error results, and results already elided are never deduplicated.
The match is exact. If a command’s output changes between runs, both runs are kept, because the later one is genuinely new information rather than a repeat.
Duplicate elision runs on its own before in-place compaction. Whenever automatic maintenance runs because context crossed the threshold or overflowed, it first runs this lossless Tier-0 pass. If dropping duplicates brings a threshold trigger back under the bar, compaction is skipped and history stays intact apart from the elided copies. Overflow recovery still finishes compaction because the prompt must be rebuilt to fit the window, but it starts from the smaller deduplicated history.
Memory backends
When memory.backend is mnemopi or hindsight, compaction can request pre-compaction context
from the active memory backend so summaries retain project facts. See Memory.
Goals
Goal cards and budgets: /goal, /guided-goal, and the goal tool. Structure: Goal state and long sessions. Operator surface: Plan mode and goals.
Role policy
Role and subagent machinery is configuration and spawn parameters, not a fixed pipeline. Intra-harness role policy chooses which model, prompt, and tool surface fits a subagent or specialized pass. Veyyon is provider-agnostic: roles are not hard-coded provider assumptions.
What exists today
- Subagents via the
tasktool (packages/coding-agent/src/task/executor.ts)./agentsopens the live hub for active and persisted agent threads. - Explicit model policies, not a role-to-model matrix: the interactive model (
/model), profile-wide subagent defaults and per-agent overrides undersubagent, pluscompaction.model.defaultis not a model or a role. Named roles (modelRoles, scoped per profile) let you pin specific work types. Edit roles under Settings → Model → Roles and subagent policy under Settings → Subagents. See Compaction & project memory and Models, roles, and profiles. - Plan / goal modes alter prompts and tool gating (
/plan,/goal). The advisor watchdog (advisor.enabledand related settings, inpackages/coding-agent/src/advisor/) is a background continuous-review mechanism rather than a mode you invoke;/advisorreports and configures it. Seedocs/handbook/src/features/advisor.md. - Addressed inter-agent messaging via the
irctool (packages/coding-agent/src/tools/irc.ts,packages/coding-agent/src/irc/bus.ts):send/wait/inbox/listops over a process-global bus.sendis fire-and-forget with delivery receipts; the bus wakes an idle recipient with a real turn, revives a parked one, or injects a non-interrupting aside into a busy one, the shipped analogue of wake-now-vs-defer message routing.wait(orsend await:true) observes the recipient’s reply as a real turn. Gated byisIrcEnabled: available to every subagent and to a top-level session that can still spawn subagents.
No fixed role pipeline
Veyyon does not enforce a staged plan → implement → verify → repair handoff. It uses lighter-weight
spawn, subagent-policy, and irc messaging patterns instead; you compose the stages yourself.
Pair role choice with execution-order prompts: explore → plan → edit → verify.
Observability
Interactive and CLI usage
- Status line token and cost segments during interactive sessions
veyyon stats(CLI) and/usage(TUI) via@veyyon/statswhen enabled- Structured logging in the coding-agent logger
OpenTelemetry
When OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is set, the process exports agent-loop traces over OTLP/protobuf. See Soundness and telemetry and packages/coding-agent/src/telemetry-export.ts.
Session debugging
/dump, /context, /debug, and standalone tool CLIs such as veyyon grep for inspecting what the agent would see.
Recording raw provider traffic
When a model behaves in a way you cannot explain from the transcript, record the
exact HTTP exchange. Set VEYYON_REQ_DEBUG=1 before starting veyyon:
VEYYON_REQ_DEBUG=1 veyyon
Every request writes two files into the directory you started veyyon from:
rr-session-N.json, the request: method, URL, headers, and body. A JSON body is stored parsed underbody; anything else is stored asbodyText, or asbodyBase64when it is not valid UTF-8.rr-session-N.res.log, the response: the status line and headers, then the raw body bytes exactly as they arrived, including every streaming chunk.
N counts up from 1 each time veyyon starts, and existing files are never
overwritten, so a second run in the same directory continues past the numbers
already there.
Two things are worth knowing before you turn it on. The files land in your
working directory rather than a cache directory, because you usually want to
read them next to the project you were working in; they are created readable by
you alone, and the repo .gitignore already covers rr-session-* so a stray
git add cannot pick one up. And a dump is the request as it went on the wire,
including file contents and whatever a provider echoed back, so treat it as
sensitive and delete it when you are done.
Credential headers do not go in. Authorization, Cookie, any header whose
name carries api-key, auth-token, access-token or secret, and their
response-side counterparts are written as <redacted N chars>: you can still
see that the header was sent and how long the value was, which is what a
debugging session needs, without the key itself sitting in a file you might
attach to a bug report. Bodies are recorded verbatim, so an OAuth token
exchange still puts a refresh token in the file.
Recording never interferes with the session it records. If a log cannot be written, because the disk is full or the directory is read-only, veyyon logs an error stating the file and the cause, stops recording that response, and lets the response through untouched. You get your answer and a truncated log, not a failed request.
Each file stops at 32 MiB. Past that the response log writes a line stating the
ceiling, then a final line stating how many bytes it recorded and how many it
omitted; a request body that ran past the ceiling contains the same counts under
bodyCapture, and the body itself is stored as bodyText rather than parsed
JSON. The response you get is unaffected: the ceiling bounds the recording, not
the request. Raise or lower it with VEYYON_REQ_DEBUG_MAX_BYTES, in bytes; a
value that is not a positive integer is a typo rather than a request for no
ceiling, so veyyon warns and keeps the default. An omitted count of null on a
request body means the sender declared no length, so the bytes past the ceiling
were never counted.
Troubleshooting
Common failure paths.
Install or startup
veyyon --version
veyyon plugin doctor
veyyon plugin doctor reports extension health and missing optional binaries/keys. Non-zero exit: fix the reported check and re-run.
Provider errors
Check API key / auth store / models.yml for that provider id, base URL, and scopes. See Models and providers.
A provider error message contains the failing status and the body the server sent, with three
limits. At most 64 KiB of the body is read, and the message states it: [truncated, showing 4096 of 65536 chars read, 134505 of 200041 bytes not read], or read stopped at 65536 bytes
when the server declared no length. Control characters and terminal escape sequences are
removed, so an error page cannot repaint the screen. Credential-shaped text is replaced with
<redacted N chars>: an Authorization or Cookie value, a bearer token, a JWT, and the
vendor key prefixes. A proxy or captive portal answering HTML instead of the provider is the
usual reason a message is truncated.
A streamed response is read one frame at a time: a line, a JSONL record, or an SSE event
ending at a blank line. One frame may occupy 64 MiB. A server or proxy that keeps sending
without ever sending the delimiter is stopped at that point, the connection is cancelled,
and the message states the protocol and both byte counts: an SSE event arrived with no blank-line dispatch: 67109376 bytes exceeded the 67108864 byte frame limit. The same
bound covers a stream of data: lines that never dispatches and a keepalive comment sent
in a loop. This failure is never retried: the next attempt reaches the same peer.
Command or edit blocked or prompting
Policy is tools.approvalMode and tools.approval, plus the working-directory and secret-use boundaries (every rung except yolo) and hard-coded flagged bash patterns: the destructive ones prompt on every rung, yolo included, and the merely dangerous ones (curl | sh, reboot, nc -e) prompt on every rung below it. There is no OS command sandbox. Schema default is auto. See Approvals and Configuration.
Truncated tool output
Tool results truncate at configured budgets; the result text should state that truncation occurred and how to continue (limit, offset, narrower query). See Bounded reads and search.
Related
Frequently asked questions
Common questions and errors. For a guided diagnostic path, see Troubleshooting.
Setup
veyyon plugin doctor fails. What do I fix?
veyyon plugin doctor exits non-zero when a check reports an error, and it prints the failed check and the next action. Fix the line it reports, then run it again. For the full diagnostics surface, see Diagnostics and health.
Does Veyyon sandbox the commands it runs?
No OS confinement (no Landlock, seccomp, Seatbelt, bubblewrap). Policy is tools.approvalMode (schema default auto), plus the working-directory and secret-use boundaries, which apply on every rung except yolo, and hard-coded flagged bash patterns: the destructive ones prompt on every rung including yolo, and the merely dangerous ones (curl | sh, reboot, nc -e) prompt on every rung below it. See Approvals.
Database and session locking
There is no cross-process lock on a session file: nothing prevents two veyyon processes from opening the same session at once, and the single-writer guarantee is per-process only. Treat one session as belonging to one running process; do not edit or delete its file while that process is alive.
For how sessions are stored and resumed, see Sessions.
Model authentication
“Invalid API key” or “Authentication failed”
The process calls the configured provider endpoint with the configured key. Check env var / auth store / models.yml for that provider, key validity, and scopes. See Models and providers.
“Unsupported region” or endpoint errors
The base URL you configured must match the provider region and product endpoint. A model id that exists in one region may not exist in another, and the same hostname may host different model catalogs. Verify the endpoint URL in your provider dashboard and compare it with the base_url in your config. Models and providers explains how provider configuration is resolved.
Why is my model not listed?
Veyyon lists models from a bundled catalog plus live discovery from providers that expose a /models endpoint. If a model is not listed, the provider endpoint may not expose it, or your key may not have access to it. Check the provider catalog and your key scopes first.
Workflow
Why did my edit ask for approval?
The approval mode sets when Veyyon prompts before a tool runs. In ask, every tier prompts, reads included. In ask-command, reads and edits run and anything that executes prompts. In auto, the default, every tier runs with the per-tool, working-directory, credential and critical-call guards still prompting. In plan, exec is blocked outright and write prompts only inside an active plan-mode session. Change mode with --approval-mode <mode> (plan, ask, ask-command, auto, yolo), --auto-approve / --yolo, or tools.approvalMode in config.yml. See Approvals.
How do I resume a session?
Run veyyon --continue to continue the most recent session, or veyyon --resume <SESSION_ID> to resume a specific one. The session stores turns and tool activity, so a resumed session keeps its context. For branching, forking, or exporting a session, see Sessions.
What happened to my queued follow-up?
Queued follow-ups live in memory for the lifetime of the running process; they are not written to the session file. If you press Esc to interrupt the current turn, queued follow-ups are pulled back into the composer so nothing is lost. See Sessions for the full queue behavior.
Why does my output look truncated?
Output is intentionally truncated when it exceeds a tool budget. The truncation should include a next action, such as increasing a limit, using an offset, or narrowing the search. See Troubleshooting for the public path.
Where to go next
- Troubleshooting for the guided diagnostic path.
- Models and providers for provider keys, endpoints, and model selection.
- Approvals for the approval modes.
- Sessions for resume, fork, branch, and export.
Diagnostics and health
veyyon setup status is the health check. It answers two things in one pass: whether the install itself works, and whether you are signed in to a provider. Plugin health has its own command, and the TUI has its own debug tools.
System health
$ veyyon setup status
$ veyyon setup status --json
The install checks run first, because nothing below them can work if the install does not. They are the same checks the installer runs at the end of every install, run against your machine as it is now:
| Check | What it proves |
|---|---|
veyyon on PATH | The shell can find it, and which file it found. |
PATH copies | Only one veyyon is on your PATH. A second one earlier on PATH keeps answering after an update writes the first, which is what makes an update look like it did nothing. |
veyyon runs | It executes and reports the version you are running. If it will not start, the check quotes the system error text. |
Native addon | A real search returns a real match, so the native addon loaded. --version alone passes without it. |
Install method | Whether veyyon update swaps the binary or advances a source checkout. |
vey alias | The short name used throughout the documentation resolves. |
Shell completions | Completion files are installed, and for which shells. On Windows that is the single script beside your PowerShell profile, since PowerShell has no directory it autoloads completions from. |
None of them touches the network. A health check you cannot run when the network is what broke is not much of a health check.
After the install checks come the credential checks: git on PATH (missing is an error), and provider authentication through OAuth or one of GEMINI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / KIMI_API_KEY (missing is a warning).
The command exits non-zero when any check reports an error, so you can gate a script on it. Warnings exit zero: they are worth reading, not worth stopping for.
Plugin doctor
$ veyyon plugin doctor
$ veyyon plugin doctor --fix
Checks plugin installation health. With --fix, it attempts automatic repairs where implemented.
TUI debug
/debug
Opens the debug tools selector in the interactive session.
Memory diagnostics
/memory diagnose
/memory stats
Run diagnostics and statistics on the configured memory backend (memory.backend: mnemopi, hindsight, local) from the TUI. See Memory.
Which one to reach for
veyyon setup statuswhen veyyon itself is misbehaving: it covers the install and your credentials.veyyon plugin doctorwhen an extension is misbehaving./debugand/memory diagnoseinside a session.- Troubleshooting for common setup failures.
Exit status
Both veyyon setup status and veyyon plugin doctor exit non-zero when a check reports an error, and zero when the worst result is a warning.
See also
Acknowledgements
Veyyon incorporates ideas and code from upstream and peer projects.
- oh-my-pi (can1357/oh-my-pi), under the MIT license. Veyyon
is a source fork of oh-my-pi: the TypeScript/Bun agent loop and TUI, the Rust natives (search, the
shell, the PTY), the hashline edit engine, provider breadth, role routing, session-tree work, and
edit ergonomics all carry forward from it. Incorporated MIT code keeps its permission notice; see
the repository
LICENSE. - codex, by OpenAI, under the Apache 2.0 license. oh-my-pi and Veyyon carry forward the codex
apply_patchpatch format and parts of the agent-loop shape as an independent TypeScript reimplementation, seeNOTICEfor exactly which files are format-compatible versus which actually vendor Apache 2.0 code (the OpenAI wire types and the Playwright ARIA-snapshot bundle do; theapply_patchparser and the Codex backend client do not). - OpenCode, under the MIT license. Veyyon studies its plan/build workflow, project memory, compact command, and file-context UI ideas.
- Lossless Claw, under the MIT license. Veyyon studies its summary DAG, fresh-tail compaction, and compacted-history inspection tools.
- command-code, by Langbase. command-code is proprietary. Veyyon only studies observable mechanisms clean-room, copying no code or bundled implementation text.
Legal credits and upstream notices live in the repository LICENSE, NOTICE, and UPSTREAM.md.
Glossary
A concise vocabulary of the primitives that shape Veyyon’s runtime behavior.
-
apply_patch: Edit mode (
edit.mode: apply_patch) for a Codex-style*** Begin Patch … *** End Patchenvelope. Default edit mode is hashline via theedittool. Apply-patch shares approval policy with other write paths. -
approval mode: The autonomy control (
tools.approvalMode) for tool tiers:plan,ask,ask-command,auto(the default),yolo(legacyalways-ask→ask,writeandauto-edit→ask-command). There is no OS command sandbox; the mode, per-tooltools.approvaloverrides, the working-directory and secret-use boundaries, and hard-coded flagged bash patterns are the boundary. Of those patterns, the destructive ones prompt on every rung,yoloincluded, and the merely dangerous ones prompt on every rung below it. -
model catalog: Bundled provider/model data plus
models.yml/models.yamlcustom entries. There is no separatebackends.tomlsubsystem. -
compaction: The compression layer that summarizes a long trajectory into a smaller, information-preserving form instead of truncating it. Compaction preserves the goal card, recent user messages, and deterministic working-set facts across successive windows.
-
edit / write: The
editandwritetools change files on disk. Defaulteditis hashline (content-hash anchors);writecreates or overwrites a whole file. Both respecttools.approvalMode. -
Freeform tool / Function tool: The two tool shapes Veyyon advertises to a model. A Freeform tool emits a raw grammar-shaped body; a Function tool emits JSON arguments matching a schema. The choice depends on the backend wire API.
-
goal state: A structured goal card on the session (session-backed). Holds the objective and lifecycle fields, injected outside the raw conversation tail so compaction does not drop intent.
-
hook: A TypeScript module that default-exports a factory and registers handlers with
pi.on(...)(events such astool_call,tool_result,session_start). Can block tools, inject context, or register commands. See Hooks. -
MCP: Model Context Protocol. Veyyon is an MCP client that connects to external MCP servers and exposes their tools as
mcp__…. Editor embedding uses ACP (veyyon acp), which is a different protocol. -
model contract / BYOK: The model contract is your chosen endpoint, model, and credentials. BYOK (bring-your-own-key) means you supply your own provider or local-endpoint key; Veyyon calls that API with your credentials. Optional OTEL export is separate and only when configured.
-
personality: Style-only system prompt block. Built-ins include
default,pragmatic,friendly, andnone. -
plugin: A directory with a
.claude-plugin/plugin.jsonmanifest (or apackage.jsoncontaining theveyyonmanifest) that can add skills, MCP servers, hooks, and related assets. Plugins are discovered through marketplaces, npm installs, orveyyon plugin link. -
profile: A directory under
~/.veyyon/profiles/<name>/(includingdefault) holding agent settings, sessions, MCP, skills, and related state. Activate with--profile,VEYYON_PROFILE, or/profile(relaunch). -
prompt-cache discipline: Keeping stable prompt prefixes byte-stable so provider prompt caches hit; context order and compaction are designed around that.
-
repair: Schema-based coercion of malformed tool-call arguments before validation; ambiguous cases return an error tool result (no dispatch). See Repair.
-
repair cascade: The ordered set of sound transforms the repair engine applies to a tool call (parse leniency, alias/typo key repair, strict unknown-key rejection, ambiguity guard). The engine returns a status (
clean/repaired/unrepairable), the coerced arguments, and coaching hints; an unrepairable call returns an error tool result without dispatch. -
rollout: The append-only JSONL log of a session’s entries. Each entry contains an
idand aparentId. Branching moves the in-memory leaf; the next appended entry’sparentId(and an optionalbranch_summaryentry) records the move without rewriting history. -
session: The unit of interactive work in Veyyon. A session records turns, tool activity, approvals, edits, and verification output.
-
skill: Filesystem package with a
SKILL.md. Loaded only from an explicit allowlist: the active profile’s skills directory, veyyon-managed skills, and plugin-bundled skills. Foreign-tool layouts never contribute skills. Metadata enters the system prompt; body is read viaskill://. -
thread / active leaf: A thread is a linear sequence of messages within a session. The active leaf is the currently selected tip of the session tree that receives the next turn; branching moves the leaf without erasing sibling history.
-
tool call: A model message that invokes a tool by name with arguments. Repair may coerce malformed arguments before validation.
-
turn: One model-invocation cycle: assemble context, model response, tool calls until the turn ends.
-
verifier / stop-when-green: Checks whether a goal or task is satisfied. Stop-when-green ends the turn loop once verification passes.
See also: Sessions, turns, and threads, Permission model, Model contract, Repair overview, and Compaction and memory.