Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

SectionContents
Design and mechanismsDesign goals and the main subsystems
Get startedInstall, sign in, first task, providers
FeaturesEditing, approvals, models, sessions, plan and goal modes, MCP, plugins, memory, profiles
ArchitectureInternals

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. edit and write apply 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.approvalMode gates 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

AreaCapability
EditsHashline edit and write, checked against file contents before writing to disk
Toolsread, search, bash, LSP, DAP, browser, MCP, task subagents, and extensions
Approvalstools.approvalMode gates read, write, and exec tiers
ModelsSeparate slots for interactive, subagent, and compaction models, with role mappings per profile
SessionsBranchable session trees with resume and fork support
MemoryLocal 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.

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. yolo does 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 /model or --model and persists as modelRoles.default.
  • Roles pin a model to specific workloads, such as smol for lightweight operations or advisor for review. Custom roles are defined in modelRoles. See Models, roles, and profiles.
  • Overrides are explicit subsystem policies. compaction.model overrides the interactive model for compaction, otherwise compaction inherits it. Subagent models are configured via subagent policies in settings.
  • Cycling rotates through cycleOrder (defaulting to smol then slow), bound to app.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.

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 sigil field.

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.

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.subagents setting controls whether child agents inherit the parent dictionary, generate a project-specific dictionary, or operate without shorthand. See Subagents configuration.

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

  1. 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.
  2. 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 / write with 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).

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

SubsystemResponsibilityChapter
Edit engine (@veyyon/hashline)Default hashline edit pathEdit engine
SessionsSession trees, compactionCompaction & memory
MCPMCP client integrationMCP
ConfigSettings and profilesConfig
Memoryoff / local / mnemopi / hindsightMemory
GoalsGoal cards and budgetsGoal state

Design rules

  1. One primary edit path. Hashline is the default edit surface; alternate edit.mode values exist for compatibility.
  2. 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.

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:

  1. Its SHA-256 digest matches the release sidecar.
  2. veyyon --version reports the exact release tag you requested.
  3. A real veyyon grep finds 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 systemArchitectureRelease binary
Linux (glibc)x64veyyon-linux-x64
Linux (glibc)arm64veyyon-linux-arm64
macOSx64 (Intel)veyyon-darwin-x64
macOSarm64 (Apple silicon)veyyon-darwin-arm64
Windowsx64veyyon-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:

SettingEffect when off
startup.checkUpdateNo version check runs at all, so nothing updates automatically.
startup.autoUpdateVeyyon 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.

ProviderEnvironment variable
openaiOPENAI_API_KEY
anthropicANTHROPIC_API_KEY (or ANTHROPIC_OAUTH_TOKEN)
googleGEMINI_API_KEY
deepseekDEEPSEEK_API_KEY
moonshotMOONSHOT_API_KEY
zaiZAI_API_KEY
openrouterOPENROUTER_API_KEY
xaiXAI_API_KEY
groqGROQ_API_KEY
mistralMISTRAL_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):

  1. A runtime --api-key for the current process (never persisted).
  2. A models.yml apiKey on a custom provider.
  3. A stored OAuth credential (refreshed as needed).
  4. A stored API key in the auth store (persisted by /login).
  5. The provider’s environment variable (including .env).
  6. 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.
  • Esc interrupts a running turn.
  • /hotkeys lists 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:

  1. The splash screen.
  2. Providers, where you sign in or paste an API key, and optionally enable web search.
  3. Glyphs, where you choose a Nerd Font, plain Unicode, or ASCII.
  4. Theme.
  5. 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:

  1. Veyyon reads the files it needs with read and search.
  2. It proposes an edit through the hashline edit and write tools.
  3. When your policy requires it, you approve the tool call. The tools.approvalMode setting sets when this happens; see Safety.
  4. The change lands, and the diff appears in the TUI.
  5. If you asked for tests, Veyyon runs them with bash; under the default auto mode 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.

ModeRuns without askingPrompts you for
planread (it proposes, but does not write)write only inside an active plan-mode session; write and exec are otherwise denied
asknothingevery call, read included
ask-commandread and writeanything that executes: bash, eval, browser, task, ssh
auto (default)all tiersa per-tool policy, a path outside the working directory, credential use, and a tool’s own flagged calls
yoloall tiersa 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. /tree jumps back to an earlier message and branches from it inside the same session file.
  • Model slots. /model sets 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
FieldMeaning
baseUrlOpenAI-compatible API root
apiRequest shape, e.g. openai-completions
apiKeyEnv-var name or literal; prefix with ! to run a shell command and use its stdout
auth: noneMark a keyless local provider
authHeader: trueInject the resolved key as Authorization: Bearer <key>
modelsList 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.cpp entry 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.

PageWhat it defines
Sessions, turns, and threadsThe 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 modelThe 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 contractThe 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.

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.

  • Permission model and Approvals cover the approval modes.
  • Approvals usually need no tuning: the default auto runs 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 veyyon launch 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

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: edit and write.
  • exec runs commands: bash and 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:

ModeAuto-approvesPrompts for
planreadwrite with an active plan-mode session; write and exec are otherwise denied
asknothingread, write, exec
ask-commandread + writeexec
autoall tiersa per-tool policy, the working-directory boundary, credential use, a tool’s own flagged calls
yoloall tiersa 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.

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:

FactWhat it isWhere it lives
EndpointBase URL and API kindA built-in provider, or a custom provider under providers: in ~/.veyyon/profiles/default/agent/models.yml
ModelThe model id the endpoint understandsPinned with --model / /model, or discovered from the provider
KeyCredential the endpoint acceptsA 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 / replace available via edit.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:

  1. Base instructions for the active model or backend (execution order, stop-when-green, format-neutral tool guidance). See Execution-order prompts.
  2. User and project instructions from global, active-profile, and project AGENTS.md layers, sticky rules, and session steers. A caller may replace the base for one invocation with --system-prompt.
  3. 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 deny policy does not filter this list; it rejects the call at dispatch.
  4. 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.

FormHow the model calls itTypical API kind
FreeformA custom / grammar tool. The raw body is the tool payload (for example a full *** Begin Patch envelope).Responses-style
FunctionA 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 disabledProviders and 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 (or smol): 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

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:

Extend and customize

These add capabilities or change how the agent runs:

FeatureWhat it adds
Plan mode, goals, and vibeEngine modes that plan, pursue an objective, or direct workers
SkillsReusable, on-demand instructions
PluginsPackaged extensions
HooksTypeScript modules that run on events with pi.on(...)
MCPModel Context Protocol servers and tools
BranchingForking a session into parallel lines of work
SubagentsDelegating work to background agents
MemoryProject-scoped recall across sessions
ProfilesIsolated config, sessions, and state per name
PersonalitiesNamed voice and behavior presets
SpeechText-to-speech output
Export and importMoving sessions in and out
ConnectorsThird-party app integrations
ApprovalsThe approval-mode boundary in depth
SecretsCredentials the agent uses by placeholder and never sees
Code reviewReviewing branches, commits, and uncommitted work
Non-interactive modeRunning 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.

PathApplierRole
edit@veyyon/hashline (default)Surgical edits, anchored on snapshot tags and hashline ops
writeWhole-file writerCreate or overwrite a file, minting new snapshot tags in hashline mode
apply_patch, patch, replaceMode-specific parsersCompatibility 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

ToolWhat the model sendsUse it for
editA hashline input (default), or a mode-specific payloadSurgical edits
writeA path plus the full contentNew files or full rewrites
apply_patchA V4A envelopeWhen 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:

  1. read (or search) returns [relative/path#TAG] and LINE:text rows.
  2. The model calls edit, anchoring each section on the same TAG.
  3. 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

TierExamples
readread, search, listing
writeedit, write
execbash and other command execution

Modes

Modereadwriteexec
planautoask with an active plan-mode session, denied otherwisedenied
askaskaskask
ask-commandautoautoask
autoautoautoauto, with the per-tool, working-directory, credential and flagged-command guards still asking
yoloautoautoauto, except a blatantly destructive command, which still prompts

Schema default: auto. Legacy aliases: always-askask, write and auto-editask-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.

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

SituationResult
Command or edit needs permissionApproval prompt with command/path and cwd
Approval deniedDenial / tool failure; no partial escalation of rights
Tool JSON malformed but unambiguousSchema repair, then validation/dispatch
Tool JSON ambiguous or unrepairableError tool result to the model; no dispatch
Tool output truncatedTruncation recorded in the tool result
Config / provider data invalidLoad 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.

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:

ScopeKey prefixCovers
Machinemachine.*Every veyyon process on this machine at once, including ones already running
Sessionsession.*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:

SettingEnforced by
cpuLimitCorescpu.max on the group
memoryLimitGbmemory.max on the group
maxProcessespids.max on the group; the fork itself is refused
writeBudgetGbio.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.max set to the core count. A positive budget smaller than one microsecond of the 100ms period writes 1 100000, not a freeze quota of 0. 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 --scope unit: a scope would block on the placeholder and leave setup failed. A positive CPUQuota too small for systemd to express floors at 0.001% rather than 0%.
  • Windows uses an unnamed Job Object with a hard CPU rate cap. CpuRate is a fraction of host logical processors (4 cores on a 16-processor machine is 2500, not 40000), counted with GetActiveProcessorCount rather 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.

CommandEffect
/cpu-limit, /cpu-limit statusReport both scopes, the values in force and what is enforcing them
/cpu-limit liftDrop this session’s CPU cap for the rest of the session
/cpu-limit resetDrop 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.

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:

DetectedNot detected
DEPLOY_TOKENTOKENIZER
API_KEYSECRETIVE_THING
KEY_FILEAUTHORIZED_USER
GPG_PASSPHRASEPASSTHROUGH
APIKEY, PRIVKEYPWD

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:

LevelPath
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 readWhere
its positionrm <name>, rename <name> <new-name>, scope <name> <vault>, extend <name> <lifetime>, from-env <VAR> <name>
a set of three wordsa vault: profile, project, global
a lifetime shape30m, 12h, 7d, 2w, never, or any word beginning with a digit
a whole numberthe 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.

CommandWhat it does
/secret listone 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> projectmove it to another vault
/secret copy <name>put #NAME# on the clipboard, never the value
/secret extend <name> 7dgive it a fresh lifetime, measured from now
/secret rm <name> [global]revoke it
/secret clear profileremove every credential in one vault, stating what it removed
/secret clear everywhereremove every credential in all three vaults
/secret log [<name>] [50]which credentials were spent, and where
/secret discard projectmove aside a vault file that cannot be read
/secret helpevery 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:

ScopeWhere it livesUse it for
profile (default)the active profile’s agent directorycredentials for one line of work
project<project>/.veyyon/vault.json, kept out of your commitscredentials for one repository
global~/.veyyon/vault.jsoncredentials 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:

LevelPathUse for
Profile<agent dir>/secrets.ymlCredentials for one line of work
Project<project>/.veyyon/secrets.ymlCredentials 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

DestinationSees the real value?
Model providerNo, a placeholder
Local session transcriptIt can. User and tool text is kept locally as written.
A session you /shareNo, a placeholder
The vault file on diskNo, encrypted
The secret-use logNo, a placeholder
secrets.yml on diskYes, it is a plain file you wrote
Your terminalOnly 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 runsYes, 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.

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
  • /login inside the TUI, which stores the credential in the auth store, or
  • A models.yml apiKey on a custom provider (an env-var name, or literal:<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 idNotes
anthropic, openai, google, groq, …Cloud providers; set the env var. Some (for example anthropic) also support /login <id>; see providers.
amazon-bedrockUses the AWS credential chain (AWS_PROFILE, instance role, …).
ollama, lm-studio, llama.cppLocal 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

ActionWhat it changesWhat it does not change
/model (or restart with --model)The interactive model for subsequent turnsThe 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

PiecePurposeConfig
Interactive modelMain conversation/model, --model; persisted as modelRoles.default
RolesNamed assignments (smol, slow, plan, advisor, …)modelRoles / Settings → Model → Roles
Subagent policyPer-agent choices, or one pair for the whole rostersubagent.agents, or subagent.sharedModel with subagent.model and subagent.thinkingLevel
Compaction overrideCompaction / handoffcompaction.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

ConstraintTypical choice
Tool-heavy refactorsHosted model with tool calling
Long sessions / subagentsChoose cheaper models under subagent and compaction.model
Low latencyLocal or flash-tier cloud
Offline / private codeOllama, LM Studio, llama.cpp
CIPin exact provider/id with --model

Pin models in CI and shared profiles (--model, modelRoles). Floating “latest” aliases change under you.

Where to go next

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 /resume inside 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 with veyyon 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.

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:

ModeWhat it shows
defaultConversation entries (hides low-signal noise)
no-toolsdefault plus hides tool-result-only assistant messages
user-onlyUser messages only
labeled-onlyEntries with labels
allEvery 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.

  • /fork duplicates 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.
  • /branch picks 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, or Ctrl+Enter where 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:

ValueWhat happens
allowedThe tool is there; the model judges when it helps, and the prompt does not request it.
preferredThe default. The prompt instructs the model to fan substantial work out instead of doing it alone.
requiredThe 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:

StateMeaning
EnabledListed in the task tool and choosable by the model. Only the bundled task worker defaults to enabled.
DisabledRefused 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:

  1. that agent’s lane, subagent.agents.<name>.model, and for a nested spawn the subagents level under it that governs that depth
  2. the agent definition’s own model: frontmatter, for an agent you wrote
  3. the profile’s default model 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.

FieldEffect
nameThe name the model spawns, and the roster row. Match the filename to keep the two findable together.
descriptionWhat the role covers. This is the text the model routes on, so state the work, not the job title.
toolsThe 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.
spawnsWhich agents this one may spawn: a list, or * for any. Omitted means none.
modelThe model this agent runs when its roster row names none. A list is a fallback chain.
thinkingLevelThe 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.
blockingtrue runs the agent to completion before the parent continues.
autoloadSkillsSkills loaded into the agent’s context at spawn.
readSummarizefalse makes its read tool return verbatim file content instead of structural summaries.
outputA 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:

KeyPurpose
statusLine.presetdefault, minimal, compact, full, nerd, ascii, or custom
statusLine.leftSegments / statusLine.rightSegmentsSegment lists when preset: custom
statusLine.enabledShow the footline at all (on by default)
statusLine.showAccountName the account serving the next request, when the provider stores more than one (off by default)
statusLine.sessionAccentTint the editor border with the session color
statusLine.showHookStatusShow 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 @high in the quiet footline, Sonnet 4.5 · high elsewhere), 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 just priority when 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

CommandEffect
/treeBrowse the session entry tree; jump or label entries
/branchBranch a new session file from an earlier user message
/forkDuplicate the current session into a new file
/session infoSession metadata and stats
/agentsSubagent dashboard: the live roster (agent type, status, activity; Enter opens one agent’s session) and the Comms stream of agent-to-agent messages
/jobsList 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

CommandAction
/hotkeysShow active chords
/settingsSettings 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:

SettingBehavior
web_search.enabledBoolean, default true. When false, the web_search tool is not offered to the model at all.
providers.webSearchWhich 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:

  1. /review: bundled interactive review command (branch, commits, or uncommitted work).
  2. Advisor: optional second model that comments on main-agent turns.
  3. Plan review: /plan-review while plan mode is active.
  4. 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.

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

OptionEffect
--no-sessionDo not persist the session (an ephemeral run)
--no-rulesDo not discover or load rules files
--no-skillsDo 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-homeStart in your home directory instead of auto-switching to a temp dir

Output and models

OptionEffect
--mode jsonMachine-readable event stream on stdout
--model / role flagsModel selection for the run
--approval-mode / --yoloApproval 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.

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

FileNameNotes
defaults/titanium.jsonTitaniumDefault dark theme. Pitch black #000000, silver #C6CBD4, ember accent #F0862E; mirrors the website tokens (website/site.css)
dark.jsonVeyyon DarkBundled alternative. Pitch black #000000 / #FAFAFA / silver #B8BDC7; predates the ember accent
light.jsonLightDefault 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: theme in ~/.veyyon/profiles/default/agent/config.yml (profile-specific when using --profile).
  • Custom themes: drop JSON under ~/.veyyon/profiles/default/agent/themes/; schema in docs/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’s statusLineBg bar, including powerline end caps.
  • A custom theme can declare a composerBg color 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.

ValueBehavior
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.
alwaysAlways paint the theme ground.
neverNever 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 after veyyon 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

CommandEffect
/collabStart 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 viewStart sharing read-only (or re-print the link/QR when already hosting)
/collab statusShow link + participants
/collab stopStop sharing
/join <link>Join a shared session as a guest
/leaveLeave (guest) or stop sharing (host)

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

SettingDefaultMeaning
collab.relayUrlwss://share.veyyon.devRelay used by /collab when no relay is passed inline
collab.webUrlemptyBrowser UI URL for /collab links; empty derives from relay; explicit http:// is allowed only for localhost
collab.displayNameOS usernameName shown to other participants
share.serverUrlhttps://share.veyyon.dev/sShare viewer/upload base used by /share (links are <base>/<id>#<key>)
share.redactSecretstrueRun 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 /collab deep link),
  • GET /r/<roomId>?role=host|guest: WebSocket upgrade,
  • POST /s / GET /s/<id> / GET /s/<id>/raw: /share blob 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:

CodeReason shownWhen
4001room closedThe host left, so the room no longer exists.
4004no such roomThe room id was never valid, or expired before the join.
4009a host is already connected for this roomA second host tried to take a room that already has one.
4029room is fullThe 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:

  1. entry frames: 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>.jsonl and into the agent’s message array, which is why /dump and context estimates work.
  2. event frames: live agent events, fed straight into the guest’s normal event controller; rendering is events-only to prevent double-render.
  3. state frames: 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.
  4. bus frames: mirrored task-subagent lifecycle/progress EventBus traffic, republished on the guest’s local bus so the subagent HUD and status-line count work natively.
  5. agents frames: 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

NeedUse
Talk to an external system (DB, SaaS, browser bridge) over a protocolMCP server
Package reusable instructions, scripts, and examples as dataSkill (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.

IntentCommand
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

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 (default true; /settings → Tasks → Modes → Plan Mode)
  • Slash: /plan toggles plan mode; /plan <prompt> enters plan mode and submits the prompt
  • /plan-review reopens plan review while plan mode is active

Behavior

  1. Session selects a plan file path and records plan-mode state.
  2. Tool surface adjusts: resolve is available for plan approval; plan-file write/edit may be enabled for drafting.
  3. The agent explores and writes the plan (read-oriented work plus plan-file edits).
  4. Finalization uses the resolve tool with plan-approval semantics (plan_approval).
  5. Exit: /plan again (confirmation if a draft exists). Session records mode entries 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 (default true)
  • /goal set <objective>: create or replace
  • /goal show: status and token usage
  • /goal pause / /goal resume
  • /goal drop: remove
  • Setting: goal.modelBudgetsEnabled (default false), 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):

ScopeLocationDescription
User.../agent/skillsSkills you author or install for the active profile.
Managed.../agent/managed-skillsAuto-learn skills Veyyon writes itself. A same-named user skill always wins.
Pluginsplugins installed into the active profileSkills 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:

  1. The compiled system prompt.
  2. The global ~/.veyyon/AGENTS.md, which applies to every profile.
  3. 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/), then AGENTS.md, then CLAUDE.md, and the first one with content wins. The rest of that directory’s candidates are not read, so a CLAUDE.md sitting beside an AGENTS.md is deliberately not loaded and the same rules are never inlined twice. The choice is made per directory, so a repository root using AGENTS.md and a package using CLAUDE.md both load.
  4. 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.md is the global file. Put rules here that should hold in every profile.
  • ~/.veyyon/profiles/<name>/agent/AGENTS.md is 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 to false to 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

  • /extensions opens 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.

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):

ScopeDirectoryPurpose
User$HOME/.veyyon/profiles/<profile>/agent/skillsSkills you author or install for the active profile.
Managed$HOME/.veyyon/profiles/<profile>/agent/managed-skillsAuto-learn skills Veyyon writes itself. A same-named user skill always wins.
Pluginsplugins installed into the active profileSkills 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 to false to 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 from SKILL.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:

  1. Open /extensions to confirm the skill is enabled, then invoke it with /skill:onboarding-check (available when skills.enableSkillCommands is on).
  2. 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:

FieldTypeDescription
nameStringThe unique name of the plugin. Lowercase ASCII alphanumerics with interior dots and hyphens (no leading/trailing separator, no underscores), at most 64 characters.
versionStringThe 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".
descriptionStringA description of the plugin (optional).
skillsString or Array of StringsPath or paths to directories containing skill definitions (optional).
commands / slash-commandsString or Array of StringsPath 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:

  1. .veyyon-plugin/marketplace.json (preferred)
  2. .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.

PathDescription
~/.veyyon/profiles/<profile>/plugins/installed_plugins.jsonUser-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.jsonEnabled state, selected features and stored settings for every npm, git and linked plugin
Project .veyyon/plugins/installed_plugins.jsonProject-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:

TargetFormExample
Marketplacename@marketplacesample@debug
npmname, @scope/name, either with @version@veyyon/[email protected]
Gitgithub:user/repo, gitlab:, bitbucket:, codeberg:, sourcehut:/srht:, or a full git URL, each with an optional #refgithub:user/repo#v1.0
Local patha 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 reports ok; 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 is chmod, not a reinstall. A profile holding only linked plugins has no package.json, because linking writes no dependency; package_manifest reports ok and states how many linked plugins are present.
  • plugin:<name>: one per installed plugin. An error means the package is missing from node_modules or has no package.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. doctor continues 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 beside agent/ and plugins/): which catalogs you have added. Each entry records the marketplace name, 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 install scope (user or project), installPath, version, install/update timestamps, the source git commit, and an enabled toggle.

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 with veyyon plugin upgrade (all) or veyyon plugin upgrade <name>@<marketplace>.
  • auto does 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.
  • off skips 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.

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.ts
  • src/extensibility/extensions/runner.ts
  • src/extensibility/extensions/wrapper.ts
  • src/extensibility/extensions/index.ts
  • src/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

  1. Extensions are imported and their factory functions run.
  2. During that load phase, registration methods are valid; runtime action methods are not yet initialized.
  3. ExtensionRunner.initialize(...) wires live actions/contexts for the active mode.
  4. Session/agent/tool lifecycle events are emitted to handlers.
  5. 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 throws ExtensionRuntimeNotInitializedError
  • 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, registerFlag
  • registerMessageRenderer, registerAssistantThinkingRenderer
  • setLabel, getFlag
  • sendMessage, sendUserMessage, appendEntry, exec
  • getActiveTools, getAllTools, setActiveTools
  • getCommands
  • getSessionName, setSessionName
  • setModel, getThinkingLevel, setThinkingLevel
  • registerProvider
  • events (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.logger
  • pi.typebox (zod-backed compatibility shim for legacy TypeBox-style schemas)
  • pi.zod (injected zod/v4 module: canonical for tool parameter schemas)
  • pi.pi (package exports)

Message delivery semantics

pi.sendMessage(message, options) supports:

  • deliverAs: "steer" (default): interrupts current run
  • deliverAs: "followUp": queued to run after current run
  • deliverAs: "nextTurn": stored and injected on the next user prompt
  • triggerTurn: true: starts a turn when idle (also honored with deliverAs: "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:

  • ui
  • hasUI
  • cwd
  • sessionManager (read-only)
  • modelRegistry, model
  • models (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 /model switches).
  • 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. Returns undefined when 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_start
  • session_before_switch / session_switch
  • session_before_branch / session_branch
  • session_before_compact / session_compacting / session_compact
  • session_before_tree / session_tree
  • session_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

  • input
  • before_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_response
  • context
  • agent_start / agent_end: agent loop lifecycle notification; agent_end remains notification-only
  • session_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 sessions
  • turn_start / turn_end
  • message_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 by wrapper.ts only 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_end
  • auto_retry_start / auto_retry_end
  • ttsr_triggered
  • todo_reminder
  • goal_updated
  • credential_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 (setTheme supports string names)
  • tools expanded toggle

Current no-op methods in this controller:

  • setFooter
  • setHeader

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, setWidget for string arrays, setEditorText; setTitle emits only when VEYYON_RPC_EMIT_TITLE=1)

Unsupported/no-op in RPC implementation:

  • onTerminalInput
  • custom
  • setFooter, setHeader, setEditorComponent, addAutocompleteProvider
  • setWorkingMessage
  • theme switching/loading (setTheme returns 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:

  1. Persist with pi.appendEntry(customType, data).
  2. Rebuild state from ctx.sessionManager.getBranch() on session_start, session_branch, session_tree.
  3. Keep tool result details structured 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_call errors 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:

  1. 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#extensions is not read: a checked-in file must not hand the agent executable modules.

  2. Installed plugins under ~/.veyyon/profiles/default/plugins/node_modules (veyyon plugin install npm/git/marketplace specs, or veyyon plugin link) via their veyyon.extensions manifests (legacy omp.extensions/pi.extensions still accepted). Marketplace installs are symlinked into the same node_modules tree, so their veyyon.extensions manifests load extension modules too.

  3. Explicit configured paths passed by the CLI (veyyon --extension ./my-ext.ts, also -e; --hook is treated as an alias) and by the extensions: 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:

  1. package.json with veyyon.extensions (legacy omp.extensions / pi.extensions) field
  2. index.ts
  3. index.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):

MethodEffect
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

NeedUse
Tools + commands + events in one moduleExtension (ExtensionAPI)
Pure event interception (policy, redaction)Extension or Hook (both work; extension is preferred)
Legacy hook module already existsHook (HookAPI from @veyyon/coding-agent/extensibility/hooks)
Registering a provider, shortcut, or CLI flagExtension only
Shipping as a marketplace pluginExtension (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.tsmy-ext.

Important constraints

  • Do not call runtime actions during load. Methods like pi.sendMessage() throw ExtensionRuntimeNotInitializedError if 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_call errors are fail-closed. If a tool_call handler 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 reference
  • docs/internal/extension-loading.md: detailed path resolution rules
  • docs/handbook/src/reference/hooks.md: hook subsystem internals
  • packages/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; --hook aliases --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:

  1. SDK-provided custom tools (options.customTools)

    • Wrapped into agent tools via CustomToolAdapter or extension wrappers.
    • Always included in the initial active tool set in SDK bootstrap.
  2. 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.
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:

  1. 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
  2. Installed plugin manifests (~/.veyyon/profiles/<profile>/plugins/node_modules/* via plugin loader; a project root <anchor>/.veyyon/plugins is enumerated the same way)
  3. 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.
  • .md and .json files 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:

  • CustomTool
  • CustomTool[]
  • Promise<CustomTool | CustomTool[]>

API surface passed to factories (CustomToolAPI)

From types.ts and loader.ts:

  • cwd: host working directory
  • exec(command, args, options?): process execution helper
  • ui: UI context (can be no-op in headless modes)
  • hasUI: false in non-interactive flows
  • logger: shared file logger
  • typebox: self-contained compatibility shim for legacy TypeBox-style schemas (legacy/compat, prefer arktype or zod for new tools)
  • zod: injected zod/v4 module (canonical for new schemas)
  • pi: injected @veyyon/coding-agent exports
  • pushPendingAction(action): register a preview action for hidden resolve tool (docs/internal/resolve-tool-runtime.md) Loader starts with a no-op UI context and requires host code to call setUIContext(...) when real UI is ready.

Execution contract and typing

CustomTool.execute signature:

execute(toolCallId, params, onUpdate, ctx, signal);
  • params is statically typed from your Zod/TypeBox schema via Static<TParams>.
  • Runtime argument validation happens before execution in the agent loop.
  • onUpdate emits partial results for UI streaming.
  • ctx includes sessionManager, modelRegistry, current model, isIdle(), hasQueuedMessages(), abort(), and optional settings, fetch, and autoApprove.
  • signal carries 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 AgentTool instances (CustomToolAdapter or 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 --tools currently 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 Box container.
  • renderResult receives { 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, shutdown
  • auto_compaction_start, auto_compaction_end
  • auto_retry_start, auto_retry_end
  • ttsr_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 execute is treated as tool failure.
  • Agent runtime converts failures into tool result messages with isError: true and error text content.
  • With extension wrappers, tool_result handlers can further rewrite content/details and even override error status.

Cancellation

  • Agent abort propagates through AbortSignal to execute.
  • Forward signal to subprocess work (pi.exec(..., { signal })) for cooperative cancellation.
  • ctx.abort() lets a tool request abort of the current agent operation.

onSession errors

  • onSession errors 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 details for renderer/state reconstruction.
  • Guard UI usage with pi.hasUI.
  • Treat .md/.json in 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 formatTypeExample
owner/repoGitHub shorthandanthropics/claude-plugins-official
https://...*.jsonDirect catalog URLhttps://example.com/marketplace.json
https://... / http://...Git repository unless the URL path ends in .jsonhttps://github.com/org/repo
git@... / ssh://...Git repository[email protected]:org/repo.git
./path or ~/path or an absolute pathLocal 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

FieldDescription
nameMarketplace name. Lowercase alphanumeric, hyphens, and dots. Must start and end with alphanumeric. Max 64 chars.
owner.nameMarketplace owner name
pluginsArray 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

FieldRequiredDescription
nameyesPlugin name (same rules as marketplace name)
sourceyesWhere to find the plugin (see below)
descriptionnoShort description
versionnoVersion string; install version falls back to plugin manifest, source SHA, then 0.0.0
authorno{ name, email? }
homepagenoURL
repositorynoRepository URL/string
licensenoLicense string
keywordsnoArray of string keywords
categorynoCategory string (e.g. development, productivity, security)
tagsnoArray of string tags
strictnoBoolean
commandsnoSlash commands provided
agentsnoAgents provided
hooksnoHook definitions
mcpServersnoMCP server definitions
lspServersnoLSP 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

FieldRequiredDescription
nameyesMarketplace name. Lowercase alphanumeric, hyphens, dots. Must start and end with alphanumeric. Max 64 chars.
owneryesObject with at minimum owner.name (string)
owner.nameyesMarketplace owner name
owner.emailnoOwner contact email
pluginsyesArray of plugin entries (see below)
metadata.descriptionnoShort description of the marketplace
metadata.versionnoCatalog metadata version string
metadata.pluginRootnoString prepended to all relative plugin source paths
extra top-level fieldsnoPreserved by the parser but not used by marketplace install/runtime logic

Plugin entry fields

FieldRequiredDescription
nameyesPlugin name (same naming rules as marketplace name)
sourceyesWhere to find the plugin, string or object (see source types below)
descriptionnoShort plugin description
versionnoVersion string
authorno{ name, email? }
homepagenoURL
categorynoe.g. development, productivity, security
tags / keywordsnoArrays of string tags/keywords
repositorynoRepository URL
licensenoLicense string
strictnoBoolean plugin metadata flag
commands, agents, hooks, mcpServers, lspServersnoCapability 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.json veyyon.extensions (legacy omp/pi) load from marketplace installs exactly as they do from npm-installed or veyyon plugin linked plugins: the install symlinks the cached plugin into the runtime node_modules tree 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

  1. Create marketplace.json at .veyyon-plugin/marketplace.json (veyyon-only) or .claude-plugin/marketplace.json (shared with Claude Code) in a new Git repo.
  2. Add plugin entries pointing to subdirectories (or external sources).
  3. Push to GitHub.
  4. Share the owner/repo string. Users add it with veyyon plugin marketplace add owner/repo.
  5. When you update the catalog, users run veyyon plugin marketplace update your-marketplace-name to 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 reference
  • docs/handbook/src/features/extensions-authoring.md: how to author the extension modules inside plugins
  • packages/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
  • 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. ExtensionAPI supports the hook event model plus extension-only events. Use ExtensionAPI for new work; use HookAPI only 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

EventFiresCan return
tool_callBefore every tool execution{ block?: boolean; reason?: string }
tool_resultAfter every tool execution{ content?; details?; isError?: boolean }

Session lifecycle

EventFiresCan return
session_startOn initial session loadn/a
session_before_switchBefore session switch{ cancel?: boolean }
session_switchAfter session switchn/a
session_before_branchBefore session branch{ cancel?: boolean; skipConversationRestore?: boolean }
session_branchAfter session branchn/a
session_before_compactBefore compaction{ cancel?: boolean; compaction?: CompactionResult }
session_compactingDuring compaction (inject context){ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
session_compactAfter compactionn/a
session_before_treeBefore tree navigation{ cancel?: boolean; summary?: { summary: string; details?: unknown } }
session_treeAfter tree navigationn/a
session_shutdownOn session shutdownn/a

Agent/turn lifecycle

EventFiresCan return
before_agent_startBefore agent starts a turn{ message?: { customType; content; display; details; attribution? } }
agent_startAgent streaming startsn/a
agent_endAgent streaming endsn/a
turn_startStart of a user→agent turnn/a
turn_endEnd of a user→agent turnn/a
contextBefore each LLM API call{ messages?: Message[] }
auto_compaction_startAuto-compaction beginsn/a
auto_compaction_endAuto-compaction endsn/a
auto_retry_startAuto-retry beginsn/a
auto_retry_endAuto-retry endsn/a
ttsr_triggeredA Time-Traveling Stream Rule (TTSR) triggeredn/a
todo_reminderTodo reminder firesn/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.
  • reason is 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: true short-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.
  • content replaces the full content array for the LLM.
  • details replaces the structured details object.
  • isError exists on the shared result type, but HookToolWrapper does 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_result is still emitted with isError: 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.messages is 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:

MethodDescription
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
themeCurrent 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 propagation
  • docs/handbook/src/features/extensions.md: ExtensionAPI (superset of HookAPI)
  • 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:

  1. The project directory contains at least one of the server’s rootMarkers.
  2. 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:

PriorityLocation
5 (lowest)~/lsp.json, ~/.lsp.json, ~/lsp.yaml, ~/.lsp.yaml, ~/lsp.yml, ~/.lsp.yml
4Plugin LSP configs (marketplace / --plugin-dir roots)
3User config dirs: ~/.veyyon/profiles/default/agent/lsp.* (active agent dir), ~/.claude/lsp.*, ~/.codex/lsp.*, ~/.gemini/lsp.*
2Project 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 idleTimeoutMs still lets Veyyon auto-detect built-in servers. When server overrides exist, Veyyon merges them with defaults and then loads servers that have matching rootMarkers, an available binary, and are not explicitly disabled.

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 to ServerConfig (optional wrapper; flat form is equivalent)
  • idleTimeoutMs: shut down idle language servers after this many milliseconds; disabled by default

ServerConfig fields

FieldTypeRequiredDescription
commandstringyesBinary name (resolved via PATH/local bins) or absolute path
argsstring[]noArguments passed to the binary
fileTypesstring[]yesFile extensions this server handles, e.g. [".ts", ".tsx"]
rootMarkersstring[]yesFiles/dirs that indicate a project root; glob patterns (e.g. *.cabal) are supported
initOptionsobjectnoSent as initializationOptions during LSP handshake
settingsobjectnoWorkspace settings pushed via workspace/didChangeConfiguration
disabledbooleannoSet to true to disable this server entirely
warmupTimeoutMsnumbernoStartup timeout in ms for this server (overrides the global default)
isLinterbooleannoMark server as linter/formatter only; excluded from type-intelligence operations (hover, go-to-definition, etc.)
capabilitiesobjectnoOpt-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 keyLanguage(s)Binary
rust-analyzerRustrust-analyzer
clangdC, C++, ObjCclangd
zlsZigzls
goplsGogopls
typescript-language-serverTypeScript, JavaScripttypescript-language-server
denolsTypeScript, JavaScript (Deno)deno
biomeTS/JS/JSON (linter)biome
eslintTS/JS/Vue/Svelte (linter)vscode-eslint-language-server
vscode-html-language-serverHTMLvscode-html-language-server
vscode-css-language-serverCSS, SCSS, Lessvscode-css-language-server
vscode-json-language-serverJSONvscode-json-language-server
tailwindcssHTML, CSS, TS/JStailwindcss-language-server
svelteSveltesvelteserver
vue-language-serverVuevue-language-server
astroAstroastro-ls
pyrightPythonpyright-langserver
basedpyrightPythonbasedpyright-langserver
pylspPythonpylsp
ruffPython (linter)ruff
jdtlsJavajdtls
kotlin-lspKotlinkotlin-lsp
metalsScalametals
hlsHaskellhaskell-language-server-wrapper
ocamllspOCamlocamllsp
elixirlsElixirelixir-ls
expertElixirexpert
erlanglsErlangerlang_ls
gleamGleamgleam
solargraphRubysolargraph
ruby-lspRubyruby-lsp
rubocopRuby (linter)rubocop
bashlsBash, Zshbash-language-server
lua-language-serverLualua-language-server
intelephensePHPintelephense
phpactorPHPphpactor
omnisharpC#omnisharp
yamllsYAMLyaml-language-server
terraformlsTerraformterraform-ls
dockerlsDockerfiledocker-langserver
helm-lsHelmhelm_ls
nixdNixnixd
nilNixnil
olsOdinols
dartlsDartdart
marksmanMarkdownmarksman
texlabLaTeXtexlab
graphqlGraphQLgraphql-lsp
prismalsPrismaprisma-language-server
vimlsVim scriptvim-language-server
emmet-language-serverHTML, CSS, JSXemmet-language-server
sourcekit-lspSwiftsourcekit-lsp
swiftlintSwift (linter)swiftlint
tlaplusTLA+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


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:

CommandEffect
/advisor statusReport whether the advisor is running, on what model, and what it has spent.
/advisor configureOpen the full-screen WATCHDOG.yml roster editor; a save applies to the running session.
/advisor on, /advisor offStart or stop the advisor for this session.
/advisor dumpCopy 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:

SurfaceEffect
advisor.enabled settingPersisted 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 flagEnable the advisor for the launched session.
WATCHDOG.ymlDefine 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:

  • read
  • search

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:

SeverityDeliveryIntended use
omitted / nitNon-interrupting aside, batched into the primary transcript at the next step boundary.Cleanup, simplification, low-risk edge cases.
concernInterrupting steering message.Material risk, likely wrong direction, missing constraint, hallucinated API.
blockerInterrupting 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:

  1. Normalization. Lowercase, NFKC, collapse every run of non-alphanumeric characters to a single space, trim. "Stop.", "*Stop*", and " stop " all key to stop.
  2. 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”.
  3. 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).
  4. Per-update rate limit. At most one note per advisor model prompt() cycle is accepted; the runtime calls host.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-up
  • 1
  • 3
  • 5

On primary turn end:

  1. the primary turn delta is queued for the advisor
  2. the advisor drain loop starts or continues in the background
  3. if advisor.syncBacklog is not off, the primary agent waits only while advisor backlog is at or above the configured threshold
  4. the wait is capped at 30 seconds
  5. if the advisor catches up below the threshold, the primary continues immediately
  6. if the cap expires, the primary continues anyway

Practical interpretation:

  • off favors maximum primary throughput.
  • 1 is the closest mode to synchronous review: after each queued advisor delta, the primary waits up to 30 seconds for backlog to return to zero.
  • 3 and 5 allow 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:

  1. user level: <active agent dir>/WATCHDOG.md (~/.veyyon/profiles/default/agent/WATCHDOG.md by default; relocated by VEYYON_CODING_AGENT_DIR / profile)
  2. project levels while walking from cwd upward 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 @path text in place

Prompt order

Loaded watchdog blocks are sorted as:

  1. user-level WATCHDOG.md
  2. 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 alongside WATCHDOG.md. Concatenated across all discovered WATCHDOG.yml files.
  • advisors[].name: human label; slugified for the session id and the <session>/__advisor.jsonl filename. Duplicate slugs across files are resolved by the same specificity rule as WATCHDOG.md discovery (project leaf > project ancestor > user).
  • advisors[].model: optional model selector with optional :level thinking suffix (e.g. x-ai/grok-code-fast:high). Omitted → the advisor uses modelRoles.advisor.
  • advisors[].tools: optional list of built-in tool names to grant. Omitted or empty → the default read/search subset. Any name in BUILTIN_TOOL_NAMES is 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 @path imports like WATCHDOG.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 rerun WATCHDOG.md discovery for that subagent session’s cwd and 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:

  1. try model-level context promotion when enabled and a larger compatible model is available
  2. if promotion cannot fit enough context, compact the advisor’s own message history
  3. 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 stats scans 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 as synthetic, agent-attributed user messages so they never inflate user-message metrics.
  • Observability. The subagent dashboard discovers __advisor.jsonl on open and shows it as a read-only advisor-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:

LineMeaning
METRIC name=valueA number the loop compares between runs.
ASI key=valueFree-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.

ToolPurpose
init_experimentOpen or reconfigure the session; set metric, direction, scope, breadth.
run_experimentRun the harness and parse its metric lines. Takes arm in autoswarm.
log_experimentRecord a run as keep, discard, crash, or checks_failed.
certify_armsTriage one iteration’s arms and assign cross-review. Attaches in autoswarm only.
update_notesEdit 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:

RejectionWhat it catches
emptyAn arm that changed nothing.
scopeAn arm that edited an off-limits path.
opaqueA diff that cannot be read: a git binary patch, or a run of 512 or more base64 characters.
duplicateAn 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:

SurvivorsReviewer
0none
1 or 2the director reviews each arm
3 or morea 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:

  1. Availability check (checkPythonKernelAvailability): verifies that a Python interpreter resolves and runs.
  2. Spawn python -u runner.py with filtered env and cwd.
  3. Send an init request that runs os.chdir(cwd), injects env entries, and adds cwd to sys.path.
  4. Execute PYTHON_PRELUDE (idempotent: only initializes once per process).

Kernel shutdown:

  • Send {"type": "exit"} over stdin.
  • Wait for process exit with SHUTDOWN_GRACE_MS budget.
  • Escalate to SIGTERM and finally SIGKILL if 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:

MagicEffect
%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.
%pwdReturns 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 VALUESet os.environ[KEY].
%time <expr> / %timeit <expr>Time the expression; emits status event with elapsed ms.
%who / %whosList user-namespace names.
%resetClear 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.
%%bashRun the cell body via /bin/bash.
%%capture [name]Run body with stdout/stderr captured into name.
%%timeitTime the cell body.
%%writefile <path>Write body to file.
!cmd / var = !cmdRun command via subprocess shell; returns an SList-style result with .n / .s helpers.
var = %name argsAssignment 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):

  1. Active/located venv (VIRTUAL_ENV, then CONDA_PREFIX, then <cwd>/.venv, <cwd>/venv)
  2. Managed venv at ~/.veyyon/python-env
  3. python or python3 on 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, or VEYYON_PY=1 VEYYON_JS=0)
  • JavaScript backend only (eval.py=false, eval.js=true, or VEYYON_PY=0 VEYYON_JS=1)
  • both backends (eval.py=true, eval.js=true, or VEYYON_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 KeyboardInterrupt inside the user code.
  • Result includes cancelled=true; a kernel timeout is annotated as eval 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_IGN for 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 exitSIGTERMSIGKILL), 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 chunks
  • display / result → rich display handling (MIME bundle)
  • error → traceback text
  • application/x-veyyon-status MIME inside display → structured status events

Display MIME precedence:

  1. text/markdown
  2. text/plain
  3. text/html (converted to basic markdown)

Additionally captured as structured outputs:

  • application/json → JSON tree data
  • image/png / image/jpeg → image payloads
  • application/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 from eval.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 that python/python3 is on PATH. If preflight fails and eval.js is enabled, use a js cell.
  • No Python on PATH: Install a system Python 3.8+ or place a venv at ~/.veyyon/python-env. veyyon setup python --check reports the resolved interpreter.
  • Execution hangs then times out: Increase tool timeout (max 3600s) if workload is legitimate. For stuck native code, cancellation triggers SIGINT first 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 cwd exists 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 overrides
  • VEYYON_PYTHON_SKIP_CHECK=1: bypass Python preflight/warm checks
  • VEYYON_PYTHON_INTEGRATION=1: enable gated integration tests that spawn a real Python
  • VEYYON_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.

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:

ScopePath
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

typeUse whenFields
stdio (default)Local executable, script, or binary.command (required), args, env, cwd
httpRemote streamable-HTTP service.url (required), headers
sseLegacy 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

CommandPurpose
/mcpList servers, connection/auth status, and exposed tools
/mcp addAdd a server (writes mcp.json)
/mcp listList 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

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:

IntegrationPurpose
MCPAttach MCP servers; tools appear as mcp__… with approval tiers
PluginsInstall extensions; veyyon plugin install …
HooksEvent-driven automation in the agent loop
SkillsBundled 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.

/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)
  • /branch when doubleEscapeAction = "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):

ModeShows
defaultConversation nodes; hides label/custom/model_change/thinking bookkeeping and tool-call-only assistant messages
no-toolsdefault plus hides tool-result messages
user-onlyUser messages only
labeled-onlyEntries with labels
allEvery 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

CommandBehavior
/branchPick 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.
/forkDuplicate 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.
CLIveyyon --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

BackendStorageNotes
offn/aNo memory injection or retention
localMarkdown under the agent memories dir (MEMORY.md, memory_summary.md, skills/)Summaries from past session files
mnemopiSQLite via @veyyon/mnemopiVector + FTS, auto-retain, compaction hooks
hindsightHindsight server (when configured)Remote bank; retain/recall/reflect tools

Enable in config:

memory:
  backend: mnemopi   # or local, hindsight, off

With memory.backend: mnemopi, Veyyon:

  1. Opens scoped SQLite banks (global, per-project, or per-project-tagged).
  2. 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.
  3. Retains completed turns on a configurable interval (mnemopi.retainEveryNTurns, default 4).
  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:

SettingKeyValues
Thresholdcompaction.thresholdauto, a percent of the window (85%), or a token amount (170000)
Typecompaction.strategysummary, the sole strategy
Modelcompaction.modelmodel 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:

  1. --profile <name>.
  2. VEYYON_PROFILE. An explicitly empty VEYYON_PROFILE= forces default, bypassing step 3.
  3. defaultProfile in the global ~/.veyyon/config.yml: set or show it with veyyon profile default [name], or edit it on the Global tab of /settings.
  4. 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; -p is --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 mywork installs a managed block in your shell rc (see cli/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:

CommandEffect
/profiles, /profileOpen the profile picker.
/profile listPrint 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
  • new creates ~/.veyyon/profiles/<name>/agent/ with the expected identity dirs (skills/, commands/, …).
  • --from default (default) seeds config.yml, keybindings, MCP, skills, and other identity files from the default profile. Sessions, blobs, and databases are not copied.
  • --from blank creates an empty agent tree.
  • --from dev seeds a blank tree, then enables the study features. It sets session.instrumentation to ultra and enables Argot, the experimental token-shorthand codec. Use this profile when you want to run veyyon session stats or 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. basic keeps the elementary timing and state record; rich adds context and communication detail; ultra adds fingerprints, abort state, links, routes, cache and reasoning detail, and upstream-provider details.
  • rm rejects the default profile, the active profile, and destructive deletes without --yes. If you remove the profile that is set as the launch default, its defaultProfile pointer 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 global defaultProfile (which profile a bare vey launches); default --clear removes 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

ConceptMeaning
Default modelThe 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.
RoleA named model assignment for a kind of work (smol, plan, advisor, and others). Configure it in modelRoles or Settings → Model → Roles.
Subagent policyThe blanket model and effort plus per-agent enabled, model, thinkingLevel, and maxNestedSpawnDepth choices under subagent.
ProfileUser config tree at ~/.veyyon/profiles/<name>/ (including default).

Interactive model

  • Set live with /model or the model picker; set for a run with --model <provider/id>.
  • On “set as default” / persist paths, the value is stored as modelRoles.default in the active profile’s config.yml.
  • There is no separate top-level model: settings key in the schema. Prefer the picker or modelRoles.default in 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 idUI nameNotes
default(hidden)Storage key for the interactive model only; not shown in role pickers or default cycleOrder
smolFastCheap / fast work; --smol or env VEYYON_SMOL_MODEL
slowThinkingHeavier reasoning; --slow or env VEYYON_SLOW_MODEL
visionVisionMultimodal work
planArchitectPlan mode; --plan or env VEYYON_PLAN_MODEL
designerDesignerDesign-oriented work
commitCommitCommit / changelog generation
tinyTinyLightweight background work: titles, classifiers
advisorAdvisorAdvisor 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

SettingEffect
subagent.sharedModelWhich 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.modelOrdered 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.thinkingLevelEffort every subagent runs at while subagent.sharedModel is on.
subagent.agentsPer-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.delegationHow strongly the model is prompted to delegate: allowed, preferred, or required.
compaction.modelOrdered 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"] (see DEFAULT_CYCLE_ORDER).
  • The string default is stripped from cycleOrder on 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:

  1. Global User Layer (~/.veyyon/AGENTS.md): Applies across EVERY profile and workspace. Reserved for cross-profile standing rules.
  2. 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):
    1. ~/.veyyon/profiles/<name>/agent/AGENTS.md (Highest)
    2. ~/.veyyon/profiles/<name>/AGENTS.md
    3. ~/.veyyon/profiles/<name>/agent/agent.md
    4. ~/.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-askask, write and auto-editask-command. See Approvals.

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.

PersonalityConfig valueEffect
DefaultdefaultBuilt-in default system personality text
PragmaticpragmaticConcise, task-focused prompt text
FriendlyfriendlyCollaborative prompt text
NonenoneNo 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: /settingsModel tab → Prompt group → Personality. Options are resolved at render time (built-ins + your ~/.veyyon/personalities and project .veyyon/personalities files); 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:

  • none is a reserved sentinel that disables the block; a file literally named none.md is 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 personality to a name that resolves to nothing (no built-in, user, or project file) falls back to default and 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

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 tts agent tool and the veyyon say CLI 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:

SettingWhat it does
speech.enabledSpeak the assistant’s output aloud as it streams.
speech.modeWhat to speak: all (messages + thinking), assistant (messages only), or yield (final message only).
speech.voiceKokoro voice used for spoken replies.
speech.enhancedRewrite 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

SettingWhat it does
stt.enabledEnable microphone speech-to-text (hold Space to talk, or bind app.stt.toggle).
stt.languageRecognition language (default en).
stt.modelNameOn-device STT model.

Synthesis backend

SettingWhat it does
providers.ttsBackend 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.localModelLocal TTS model (kokoro).
tts.localVoiceDefault Kokoro voice (see veyyon say --voices).
speechgen.enabledEnable 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 skills directory (~/.veyyon/profiles/<profile>/agent/skills)
  • CLAUDE.md/AGENTS.md content → appended to the profile AGENTS.md under 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.

ScopePathNotes
Profile~/.veyyon/profiles/<name>/agent/config.ymlThe main persistent file. /settings and veyyon config set write here.
Machine-global~/.veyyon/config.ymlThe few keys shared by every profile (defaultProfile, profileSharing, auth-broker keys).
CLI overlayany 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_cwd tool / /cwd): Re-roots the active session’s working directory temporarily. It never writes session.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.md and 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:

filewhen veyyon writes it
config.ymlyou change a setting from /settings or veyyon config set
~/.veyyon/config.ymlyou set a default profile or profile sharing
keybindings.ymla binding you wrote uses a name from an older release
WATCHDOG.ymlyou 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:

GoalWhat to set
Choose the model you talk to--model / /model (persisted as modelRoles.default)
Run every subagent on one model and effortsubagent.sharedModel, then subagent.model and subagent.thinkingLevel
Customize one subagentsubagent.agents.<name> or Settings → Subagents → Agents
Choose the model for context compactioncompaction.model
Add named model assignmentsmodelRoles, per profile (Settings → Model → Roles)
Add a local or BYOK providera 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)

GoalWhat to set
When Veyyon asks before actingtools.approvalMode: plan, ask, ask-command, auto (default), yolo; legacy always-ask/write/auto-edit accepted
Per-tool policytools.approval: map a tool to allow / deny / prompt
Advisor review passadvisor.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

GoalWhat to pass
Non-interactive one-shotveyyon --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:

GoalWhat to set
Auto-compaction thresholdcompaction.threshold: auto, a percent (85%), or a token amount (170000)
Compaction typecompaction.strategy: summary, the sole strategy
Compaction modelcompaction.model (unset = interactive model)
Cross-session memory backendmemory.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 write while 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
  • --print output 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 through argot_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:

GoalWhat to set
Per-profile default cwdsession.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.

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.

KeyDefaultEffect
features.unexpectedStopDetectionoffUse 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.

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.

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

  1. 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.

  2. 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 doctor and completed one normal session.

  3. 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

  1. Edit ~/.veyyon/profiles/default/agent/config.yml (or the active profile agent dir). Use ~/.veyyon/config.yml only for global keys such as defaultProfile.

  2. Run the new binary once to see any validation errors:

    veyyon --version
    veyyon plugin doctor
    
  3. Fix each reported line. If you are unsure what a key does, see Configuration and File locations.

  4. After editing, run veyyon plugin doctor again 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

  1. Close Veyyon.
  2. Remove the state database file (see File locations for the exact path on your platform).
  3. 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.

  1. Close all Veyyon processes.
  2. Restore the previous binary. Run veyyon rollback to pick an earlier version, or re-run the curl installer 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 with git checkout v<version> and rebuild.
  3. Restore profile config.yml (and global ~/.veyyon/config.yml if you changed it) from the backup you made before upgrading, if the new version modified settings the old version cannot read.
  4. 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.
  5. Start Veyyon and run veyyon plugin doctor to 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

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 veyyon command 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:

OptionPurpose
[PROMPT]Optional initial user prompt
-c, --continueContinue 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:

CommandAliasesPurpose
launch(default)Interactive or prompted session
acpAgent Client Protocol server mode
agentsManage agent definitions
auth-brokerShared auth broker (headless login)
auth-gatewayAuth gateway helper
bench/throughputThroughput benchmark harness
commitAgentic commit workflow
completionsShell completion scripts
configList/get/set settings
dry-balanceDry-run OAuth account balancing
gcGarbage-collect session artifacts
grepGrep-tool CLI probe
galleryTUI gallery / fixtures
grievancesInternal grievance reporter
installInstall or link an extension package (alias of plugin install/plugin link)
joinJoin collab session
licensesPrint Veyyon and third-party license notices
modelsList models and providers
pluginPlugin lifecycle (list, install, …)
profileprofilesList, create, or remove self-contained profiles
promptInspect the assembled system prompt without starting a session
readRead-tool CLI probe
rollbackMove this install to another published version
saySpeak text with local TTS (--voices lists voices)
searchqWeb search probe
sessionsessionsStudy a stored session (stats: timing, tool cost, turn cadence)
setupFirst-run setup wizard
shellNative shell probe
sshSSH host configuration
statsUsage statistics dashboard (--summary prints to console, --json for machines)
tiny-modelsOn-device tiny model utilities
tokenPrint a provider’s API key or OAuth token
ttsrTime-traveling stream rules test
trustDecide whether this project’s code may run (Project trust)
updateSelf-update
usageProvider usage limits
worktreewtGit 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:

  • off stores the normal resumable conversation and tool history without extra telemetry. Stats still use normal assistant usage and messages.
  • basic adds lifecycle and checkpoints, task-state transitions, tool wall-clock and status, and model request timing.
  • rich adds context attribution, agent-message delivery, tool scheduling and result weight, model token throughput, and richer rollups.
  • ultra adds 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

CommandPurpose
/new, /freshNew session (fresh may reset provider stream state)
/resumeResume another saved session
/fork, /branch, /treeBranching 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
/dumpDump debug artifacts
/session info, /session deleteSession metadata or delete
/profile [name], /profilesBare 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
/welcomeShow the full welcome screen (actions, recent sessions)
/exit, /quit, /pauseLeave or pause

Model, modes, and behavior

CommandPurpose
/model [id], /modelsSelect the interactive model only (no role cycle; roles live in settings)
/switchTry a model for this session only, without saving it as default (same as alt+p)
/fast on|off|statusFast 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|statusRemove this session’s permission prompts (a blatantly destructive command, an explicit deny, and plan mode still block; needs confirmation)
/planToggle plan mode
/plan-reviewRe-open plan review
/goal …Goal set/show/pause/resume/drop/budget
/guided-goalGuided goal wizard
/loopLoop 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
/secretStore 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, /setupSettings UI; /setup opens first-run provider sign-in
/providers, /account managerOpen the account manager: every stored account per provider, with its email, plan, health, and usage. See Authentication
/account statusShow 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
/statuslineSettings UI, jumped to Status Line (preset/segments/separator)
/reload-pluginsReload extensions
/trustDecide 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

CommandPurpose
/compact [summary] [focus]Summarize older context in place; optional focus string
/shake elide|imagesShake tool-result bulk. A bare /shake opens the picker
/handoff [focus]Explicitly transfer context into a new session
/contextContext usage report
/toolsTools visible to the model
/jobsBackground async jobs
/todo …Todo list CRUD
/browser …Browser tool mode
/memory …Memory backend view/stats/clear/enqueue
/copyPick text or code from the conversation to copy
/rephraseAsk for the last reply again, in plainer prose. Needs a finished reply to work from
/lspShow language server status

Auth and usage

CommandPurpose
/login [provider|url]OAuth / API key login
/logout [provider]Log out
/usage show|resetProvider 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
/changelogOpen the release notes on the web

Extensions

CommandPurpose
/mcp …MCP server management
/mcp notificationsShow notification capabilities and subscriptions
/plugins …Plugin browser
/extensions, /statusExtension 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
/hotkeysActive keybinding chords
/collab …, /join, /leaveLive collab sessions
/shareShare the session via an encrypted link (share server or secret gist)

Side agents and misc

CommandPurpose
/btwEphemeral side question
/tanRun a full background agent on tangential work
/advisor …Show, configure, start or stop the advisor that reviews each turn
/omfgForge a TTSR rule from a complaint to stop a recurring behavior
/vibeToggle vibe mode (director + vibe_* worker tools)
/retryRetry failed turn
/debugDebug overlays
/queueQueue follow-up message
/dropDelete 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.

CommandSubcommands
/setupproviders
/accountstatus, manager, switch, use, name, refresh, usage, login, logout
/goalset, show, pause, resume, drop
/faston, off, status
/permissionsstatus, ask, ask-command, auto, yolo, plan, reset
/yoloon, off, status
/cpu-limitstatus, lift, reset
/secretadd, from-env, list, rm, clear, rename, value, scope, copy, extend, log, discard, help
/collabstart, view, status, stop
/browserheadless, visible
/todoedit, copy, export, import, append, start, done, drop, rm
/sessioninfo, delete
/usageshow, reset
/mcpadd, list, remove, test, reauth, unauth, enable, disable, smithery-search, smithery-login, smithery-logout, reconnect, reload, resources, prompts, notifications, help
/sshadd, list, remove, help
/compactsummary
/shakeelide, images
/memoryview, stats, diagnose, clear, reset, enqueue, rebuild, mm list, mm show, mm refresh, mm history, mm seed, mm delete, mm reload
/pluginslist
/trustapprove, deny, forget
/advisorstatus, 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

  1. Model emits a tool call (JSON arguments per schema).
  2. Veyyon validates arguments; handlers run after approval checks.
  3. 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

ToolPurpose
editApply changes, default hashline (edit.mode: hashline); also apply_patch / patch / replace modes
writeCreate 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.

ToolPurpose
readFiles, dirs, URLs, archives, SQLite, memory://, skill://, …
searchUnified workspace search (files/paths, text/regex, and code structure)
search_tool_bm25Discover tools by description (when enabled)

Shell and execution

ToolPurpose
bashShell commands, gated by the approval mode
sshRemote commands via configured hosts
evalJS/Python/Julia/Ruby eval cells (when enabled)
debugDebugger integration
browserBrowser automation
jobBackground 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

ToolPurpose
taskSpawn subagents
ircInter-agent messaging
todoStructured task lists
goalGoal card updates (with goal mode)
askUser questions
yieldYield turn for follow-ups

Memory (when backend enabled)

ToolPurpose
recall, retain, reflect, memory_editMnemopi/hindsight surfaces
learnAutolearn (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

BindingAction
escInterrupt the current operation (app.interrupt)
ctrl+cClear screen or cancel (app.clear)
ctrl+dExit the application (app.exit)
ctrl+zSuspend the application (app.suspend)
ctrl+bMove 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+lReset the terminal display (app.display.reset)
shift+tabCycle thinking level (app.thinking.cycle)
ctrl+tToggle thinking mode (app.thinking.toggle)
ctrl+p / shift+ctrl+pCycle model forward / backward (app.model.cycleForward / app.model.cycleBackward)
alt+mSelect model (app.model.select)
alt+pSelect a temporary model for the current session (app.model.selectTemporary)
ctrl+oExpand tool output (app.tools.expand)
ctrl+gOpen the draft in an external editor (app.editor.external)
ctrl+q or ctrl+enterSend a follow-up message (app.message.followUp)
alt+rRetry the last failed assistant turn (app.retry)
alt+upDequeue a queued message back into the editor (app.message.dequeue)
alt+shift+pToggle plan mode (app.plan.toggle)
alt+aOpen the subagent dashboard (app.agents.hub)
ctrl+rSearch prompt history (app.history.search)
alt+shift+lCopy the current line (app.clipboard.copyLine)
alt+shift+cCopy 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+vPaste 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

BindingAction
enterSubmit the current message (tui.input.submit)
shift+enter or ctrl+jInsert a new line (tui.input.newLine)
tabAutocomplete (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

BindingAction
up / downMove cursor up / down
left or ctrl+bMove cursor left
right or ctrl+fMove cursor right
alt+left, ctrl+left, or alt+bMove cursor left by one word
alt+right, ctrl+right, or alt+fMove cursor right by one word
home or ctrl+aMove cursor to the start of the line
end or ctrl+eMove cursor to the end of the line
ctrl+] / ctrl+alt+]Jump forward / backward to a character
page-up / page-downPage up / down
backspaceDelete the character to the left
delete or ctrl+dDelete the character to the right
ctrl+w, alt+backspace, ctrl+backspace, or super+alt+backspaceDelete the word to the left
alt+delete, alt+d, super+alt+delete, or super+alt+dDelete the word to the right
ctrl+uDelete from the cursor to the start of the line
ctrl+kDelete from the cursor to the end of the line
ctrl+y / alt+yYank / yank-pop the kill buffer
ctrl+- or ctrl+_Undo

Lists and selectors

BindingAction
up / downMove the selection up / down
page-up / page-downMove the selection by one page
enterConfirm the selection
esc or ctrl+cCancel 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 IDDefaultMeaning
app.model.cycleForwardCtrl+PCycle role models forward
app.model.cycleBackwardShift+Ctrl+PCycle role models backward
app.model.selectTemporaryAlt+PPick a model temporarily for this session
app.model.selectAlt+MOpen the model selector and set roles
app.plan.toggleAlt+Shift+PToggle plan mode
app.history.searchCtrl+RSearch prompt history
app.tools.expandCtrl+OToggle tool-output expansion
app.thinking.toggleCtrl+TToggle thinking-block visibility
app.thinking.cycleShift+TabCycle thinking level
app.editor.externalCtrl+GEdit the draft in $VISUAL / $EDITOR
app.message.followUpCtrl+Q, Ctrl+EnterQueue a follow-up message
app.message.dequeueAlt+UpDequeue a queued message back into the editor
app.retryAlt+RRetry the last failed assistant turn
app.display.resetCtrl+LReset terminal display
app.bash.backgroundCtrl+BMove 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.copyLineAlt+Shift+LCopy the current line
app.clipboard.copyPromptAlt+Shift+CCopy the whole prompt
app.clipboard.pasteImageCtrl+V (Alt+V fallback on Windows, Super+V on macOS)Paste from the clipboard (image preferred, text fallback)
app.stt.toggleUnbound (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.

GestureWhenEffect
Down arrowA goal is active or pausedOpen 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 emptyOpen 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:

VariablePurpose
VEYYON_CONFIG_DIROverrides 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_DIRFull override for the agent directory (default ~/<config-dir-name>/profiles/<active-or-default>/agent).
VEYYON_PROFILESelects the active named profile (~/.veyyon/profiles/<name>/agent).
VEYYON_PACKAGE_DIROverride package directory for bundled assets (Nix/Guix).
VEYYON_NO_PTYSet to 1 to disable PTY-based interactive bash.
VEYYON_NO_TITLESet to disable model-generated session auto-titling (the terminal window title then falls back to the directory name).
VEYYON_PIPED_STDIN_WAIT_MSMilliseconds 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_DIRAbsolute 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_DBFull path override for the GitHub view cache database (default ~/.veyyon/profiles/<name>/cache/github-cache.db).
VEYYON_STREAM_FRAME_MAX_BYTESBytes 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.

ProviderVariable
OpenAIOPENAI_API_KEY
AnthropicANTHROPIC_API_KEY (or ANTHROPIC_OAUTH_TOKEN, which takes precedence)
DeepSeekDEEPSEEK_API_KEY
MoonshotMOONSHOT_API_KEY
Z.AIZAI_API_KEY
OpenRouterOPENROUTER_API_KEY
Google GeminiGEMINI_API_KEY
xAIXAI_API_KEY (or XAI_OAUTH_TOKEN, which takes precedence for xai-oauth)
GroqGROQ_API_KEY
MistralMISTRAL_API_KEY
CursorCURSOR_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

VariablePurpose
OLLAMA_BASE_URL / OLLAMA_HOSTOllama discovery base URL (defaults to http://127.0.0.1:11434).
LM_STUDIO_BASE_URLLM Studio discovery base URL (defaults to http://127.0.0.1:1234/v1).
LLAMA_CPP_BASE_URLllama.cpp discovery base URL (defaults to http://127.0.0.1:8080).
LITELLM_BASE_URLLiteLLM proxy base URL fallback (defaults to http://localhost:4000/v1).
VEYYON_EDIT_VARIANTForce 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

VariablePurpose
NODE_EXTRA_CA_CERTSExtra 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_KEYmTLS 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

VariablePurpose
VEYYON_INSTALL_DIROverrides 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_SETUPSkips 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.

VariablePurpose
VEYYON_MCP_TIMEOUT_MSOverrides 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:

VariablePurpose
VEYYON_AUTH_BROKER_URLBase URL of the remote auth-broker; selects broker mode.
VEYYON_AUTH_BROKER_TOKENBearer token sent to the broker.
VEYYON_AUTH_BROKER_SNAPSHOT_TTL_MSFreshness window (ms) for the encrypted local snapshot cache; default 3600000.
VEYYON_AUTH_BROKER_SNAPSHOT_CACHEPath to the encrypted local snapshot cache.

Most installs never set these. Details: docs/internal/auth-broker-gateway.md.

Repair

VariablePurpose
VEYYON_REPAIR_DISABLESet 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

VariablePurpose
NO_COLORWhen set (to any value), Veyyon renders without color; hierarchy comes through emphasis, spacing, and glyphs instead.
TERM / COLORTERMRead to detect terminal capabilities and choose truecolor, ANSI-256, or ANSI-16 output. TERM=dumb disables every ANSI escape, including emphasis.
FORCE_COLORA non-empty value other than 0 forces full ANSI output, overriding NO_COLOR and TERM=dumb.
VEYYON_HARDWARE_CURSORTruthy enables hardware cursor mode.
VEYYON_TUI_WRITE_LOGWhen 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

NameStatus
VEYYON_HOMENever existed. Config location is VEYYON_CONFIG_DIR (dirname override) + optional XDG migration, not a single home-path variable.
VEYYON_SQLITE_HOMENever existed. No separate SQLite-state override; state lives under the resolved agent directory.
VEYYON_API_KEY / VEYYON_ACCESS_TOKENNever existed as a Veyyon-branded credential; use each provider’s native key variable.
VEYYON_AUTHAPI_BASE_URLNever existed. The ChatGPT AuthAPI host used by personal-access-token whoami is not overridable via env today.
VEYYON_APP_SERVER_LOGIN_ISSUERBelonged to the removed app-server daemon; no equivalent exists in this runtime.
VEYYON_MANAGED_BY_NPM / VEYYON_MANAGED_BY_BUN / VEYYON_MANAGED_PACKAGE_ROOTNever existed.
VEYYON_SANDBOX / VEYYON_SANDBOX_NETWORK_DISABLED / VEYYON_THREAD_IDNever 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/** and packages/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:

  1. Existing process environment (Bun.env)
  2. Project .env ($PWD/.env) for keys not already set
  3. Agent .env (~/.veyyon/profiles/default/agent/.env, respecting VEYYON_CONFIG_DIR / VEYYON_CODING_AGENT_DIR) for keys not already set
  4. Config-root .env (~/.veyyon/profiles/default/.env, respecting VEYYON_CONFIG_DIR and the active profile) for keys not already set
  5. 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.

VariableUsed forRequired whenNotes / precedence
OPENAI_CODEX_OAUTH_TOKENOpenAI Codex OAuth authUsing openai-codex provider
BASETEN_API_KEYBaseten authUsing baseten provider
CMD_API_KEYCommand Code authUsing command-code providerPreferred key alias; endpoint https://api.commandcode.ai/provider/v1, default moonshotai/Kimi-K2.7-Code
COMMAND_CODE_API_KEYCommand Code authUsing command-code provider without CMD_API_KEYFallback after CMD_API_KEY; keys are issued at https://commandcode.ai/studio/provider
NOUS_API_KEYNous Research explicit/headless authUsing nous-research without a /loginEither 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_KEYCoreWeave authUsing coreweave providerTakes precedence over WANDB_API_KEY
WANDB_API_KEYCoreWeave authUsing coreweave providerFallback after COREWEAVE_API_KEY
DEVIN_API_KEYDevin authUsing devin provider
SAKANA_API_KEYSakana authUsing sakana providerTakes precedence over FUGU_API_KEY
FUGU_API_KEYSakana authUsing sakana providerFallback after SAKANA_API_KEY
ANTHROPIC_OAUTH_TOKENAnthropic API authUsing Anthropic with OAuth token authTakes precedence over ANTHROPIC_API_KEY for provider auth resolution
ANTHROPIC_API_KEYAnthropic API authUsing Anthropic without OAuth tokenFallback after ANTHROPIC_OAUTH_TOKEN
ANTHROPIC_FOUNDRY_API_KEYAnthropic via Azure Foundry / enterprise gatewayCLAUDE_CODE_USE_FOUNDRY enabledTakes precedence over ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY when Foundry mode is enabled
OPENAI_API_KEYOpenAI authUsing OpenAI-family providers without explicit apiKey argumentUsed by OpenAI Completions/Responses providers
GEMINI_API_KEYGoogle Gemini authUsing google provider modelsPrimary key for Gemini provider mapping
GOOGLE_API_KEYGemini image tool auth fallbackUsing gemini_image tool without GEMINI_API_KEYUsed by coding-agent image tool fallback path
GROQ_API_KEYGroq authUsing Groq models
CEREBRAS_API_KEYCerebras authUsing Cerebras models
FIREWORKS_API_KEYFireworks authUsing Fireworks models
FIREPASS_API_KEYFire Pass authUsing Fire Pass models
TOGETHER_API_KEYTogether authUsing together provider
AIMLAPI_API_KEYAIML API authUsing aimlapi providerOpenAI-compatible AIML API endpoint at https://api.aimlapi.com/v1
HUGGINGFACE_HUB_TOKENHugging Face authUsing huggingface providerPrimary Hugging Face token env var
HF_TOKENHugging Face authUsing huggingface providerFallback when HUGGINGFACE_HUB_TOKEN is unset
SYNTHETIC_API_KEYSynthetic authUsing Synthetic models
NVIDIA_API_KEYNVIDIA authUsing nvidia provider
NANO_GPT_API_KEYNanoGPT authUsing nanogpt provider
NOVITA_API_KEYNovita authUsing novita provider
VENICE_API_KEYVenice authUsing venice provider
LITELLM_API_KEYLiteLLM authUsing litellm providerOpenAI-compatible LiteLLM proxy key
LM_STUDIO_API_KEYLM Studio auth (optional)Using lm-studio provider with authenticated hostsLocal LM Studio usually runs without auth; any non-empty token works when a key is required
OLLAMA_API_KEYOllama auth (optional)Using ollama provider with authenticated hostsLocal Ollama usually runs without auth; any non-empty token works when a key is required
LLAMA_CPP_API_KEYllama.cpp auth (optional)Using llama.cpp provider with authenticated hostsLocal llama.cpp usually runs without auth; any non-empty token works when a key is configured
XIAOMI_API_KEYXiaomi MiMo authUsing xiaomi provider
XIAOMI_TOKEN_PLAN_AMS_API_KEYXiaomi MiMo Token Plan auth (AMS)Using xiaomi-token-plan-ams provider
XIAOMI_TOKEN_PLAN_CN_API_KEYXiaomi MiMo Token Plan auth (CN)Using xiaomi-token-plan-cn provider
XIAOMI_TOKEN_PLAN_SGP_API_KEYXiaomi MiMo Token Plan auth (SGP)Using xiaomi-token-plan-sgp provider
MOONSHOT_API_KEYMoonshot authUsing moonshot provider
XAI_API_KEYxAI authUsing xAI models or as fallback for xai-oauth
XAI_OAUTH_TOKENxAI OAuth/SuperGrok authUsing xai-oauth providerTakes precedence over XAI_API_KEY for xai-oauth
OPENROUTER_API_KEYOpenRouter authUsing OpenRouter modelsAlso used by image tool when preferred/auto provider is OpenRouter
MISTRAL_API_KEYMistral authUsing Mistral models
ZAI_API_KEYz.ai authUsing z.ai modelsAlso used by z.ai web search provider
ZHIPU_API_KEYZhipu Coding Plan authUsing zhipu-coding-plan provider
UMANS_AI_CODING_PLAN_API_KEYUmans AI Coding Plan authUsing umans provider
MINIMAX_API_KEYMiniMax authUsing minimax provider
MINIMAX_CODE_API_KEYMiniMax Code authUsing minimax-code provider
MINIMAX_CODE_CN_API_KEYMiniMax Code CN authUsing minimax-code-cn provider
OPENCODE_API_KEYOpenCode authUsing opencode-go / opencode-zen models
QIANFAN_API_KEYQianfan authUsing qianfan provider
QWEN_OAUTH_TOKENQwen Portal authUsing qwen-portal with OAuth tokenTakes precedence over QWEN_PORTAL_API_KEY
QWEN_PORTAL_API_KEYQwen Portal authUsing qwen-portal with API keyFallback after QWEN_OAUTH_TOKEN
ZENMUX_API_KEYZenMux authUsing zenmux providerUsed for ZenMux OpenAI and Anthropic-compatible routes
VLLM_API_KEYvLLM auth/discovery opt-inUsing vllm provider (local OpenAI-compatible servers)Any non-empty value works for no-auth local servers
CURSOR_ACCESS_TOKENCursor provider authUsing Cursor provider
AI_GATEWAY_API_KEYVercel AI Gateway authUsing vercel-ai-gateway provider
CLOUDFLARE_AI_GATEWAY_API_KEYCloudflare AI Gateway authUsing cloudflare-ai-gateway providerBase URL must be configured as https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic
ALIBABA_CODING_PLAN_API_KEYAlibaba Coding Plan authUsing alibaba-coding-plan provider
DEEPSEEK_API_KEYDeepSeek authUsing DeepSeek models
KILO_API_KEYKilo authUsing Kilo models
OLLAMA_CLOUD_API_KEYOllama Cloud authUsing ollama-cloud provider
WAFER_SERVERLESS_API_KEYWafer Serverless authUsing wafer-serverless providerPay-as-you-go Wafer SKU; validated against https://pass.wafer.ai/v1/models
GITLAB_TOKENGitLab Duo authUsing 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

VariableUsed forNotes
COPILOT_GITHUB_TOKENGitHub Copilot provider authGeneric GitHub tokens are not used here
GH_TOKENGitHub API auth in web scraperWeb scraper fallback after GITHUB_TOKEN
GITHUB_TOKENGitHub API auth in web scraperWeb 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.

VariableUsed forRequired whenNotes / precedence
VEYYON_AUTH_BROKER_URLBase URL of the remote auth-broker (e.g. https://broker.tailnet:8765); selects broker modeResolving 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_TOKENBearer token sent on every broker endpoint except /v1/healthzVEYYON_AUTH_BROKER_URL is set and no token is available from auth.broker.token or <config-dir>/auth-broker.tokenResolution: 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_MSFreshness window for the encrypted local broker snapshot cacheOptional in broker modeDefault 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_CACHEPath to the encrypted local broker snapshot cacheOptional in broker modeDefaults 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 anthropic becomes: ANTHROPIC_FOUNDRY_API_KEYANTHROPIC_OAUTH_TOKENANTHROPIC_API_KEY.

  • ANTHROPIC_CUSTOM_HEADERS is parsed as comma/newline-separated key: value pairs and merged into request headers. They are also forwarded when ANTHROPIC_BASE_URL points 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 \n sequences).

    NODE_EXTRA_CA_CERTS is honoured for every provider fetch (OpenAI-compatible, Codex, Ollama, Azure Responses, Google, Anthropic), not just Foundry, Bun’s fetch does not consume the env var natively, so the bundle is merged into RequestInit.tls.ca alongside the system root store. The CLAUDE_CODE_* mTLS material remains Anthropic-Foundry-specific.

VariableValue typeBehavior
CLAUDE_CODE_USE_FOUNDRYBoolean-like string (1, true, yes, on)Enables Foundry mode for Anthropic provider
FOUNDRY_BASE_URLURL stringAnthropic endpoint base URL in Foundry mode
ANTHROPIC_FOUNDRY_API_KEYToken stringUsed for Authorization: Bearer <token>
ANTHROPIC_CUSTOM_HEADERSHeader list stringExtra 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_CERTSPEM path or inline PEMExtra CA chain for server certificate validation
CLAUDE_CODE_CLIENT_CERTPEM path or inline PEMmTLS client certificate
CLAUDE_CODE_CLIENT_KEYPEM path or inline PEMmTLS client private key (must be paired with cert)

Amazon Bedrock

VariableDefault / behavior
AWS_REGIONPrimary region source
AWS_DEFAULT_REGIONFallback if AWS_REGION unset
AWS_PROFILEEnables named profile auth path
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEYEnables IAM key auth path
AWS_BEARER_TOKEN_BEDROCKHighest-precedence bearer token auth path; skips AWS profile/credential-chain lookup when set
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI / AWS_CONTAINER_CREDENTIALS_FULL_URIMarks 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_ARNMarks Bedrock as available in provider detection (same caveat as the ECS variables above)
AWS_BEDROCK_SKIP_AUTHIf 1, injects dummy credentials (proxy/non-auth scenarios)
HTTPS_PROXY / HTTP_PROXYHonored via Bun’s native fetch proxy support (the provider no longer ships an AWS SDK / proxy-agent transport)
NO_PROXYExcludes matching hosts from Bun’s native proxy routing

Region fallback in provider code: options.regionAWS_REGIONAWS_DEFAULT_REGIONus-east-1.

Azure OpenAI Responses

VariableDefault / behavior
AZURE_OPENAI_API_KEYRequired unless API key passed as option
AZURE_OPENAI_API_VERSIONDefault v1
AZURE_OPENAI_BASE_URLDirect base URL override
AZURE_OPENAI_RESOURCE_NAMEUsed to construct base URL: https://<resource>.openai.azure.com/openai/v1
AZURE_OPENAI_DEPLOYMENT_NAME_MAPOptional 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

VariableRequired?Notes
GOOGLE_CLOUD_PROJECTYes (unless passed in options)Primary project ID source
GCP_PROJECTFallbackAlternate project ID source
GCLOUD_PROJECTFallbackAlternate project ID source
GOOGLE_CLOUD_PROJECT_IDOAuth login helper onlyUsed by Gemini CLI OAuth project discovery
GOOGLE_VERTEX_LOCATIONYes (unless passed in options)Primary Vertex location source
GOOGLE_CLOUD_LOCATIONFallbackAlternate Vertex location source
VERTEX_LOCATIONFallbackAlternate Vertex location source
GOOGLE_CLOUD_API_KEYConditionalDirect Vertex API-key auth; otherwise ADC fallback can authenticate when project and location are set
GOOGLE_APPLICATION_CREDENTIALSConditionalIf set, file must exist; otherwise ADC fallback path is checked (~/.config/gcloud/application_default_credentials.json)

Kimi

VariableDefault / behavior
KIMI_CODE_OAUTH_HOSTPrimary OAuth host override
KIMI_OAUTH_HOSTFallback OAuth host override
KIMI_CODE_BASE_URLOverrides Kimi usage endpoint base URL (usage/kimi.ts)

OAuth host chain: KIMI_CODE_OAUTH_HOSTKIMI_OAUTH_HOSThttps://auth.kimi.com.

Gemini CLI compatibility

VariableDefault / behavior
VEYYON_AI_GEMINI_CLI_VERSIONOverrides the Gemini CLI user-agent version tag (0.46.0 if unset)
VEYYON_AI_ANTIGRAVITY_VERSIONOverrides the Antigravity / Cloud Code Assist user-agent version tag (2.1.4 if unset)

OpenAI Codex responses (feature/debug controls)

VariableBehavior
VEYYON_CODEX_DEBUG1/true enables Codex provider debug logging
VEYYON_CODEX_WEBSOCKET1/true enables websocket transport preference
VEYYON_OPENAI_STATEFULOverrides 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_MSPositive integer override (default 300000)
VEYYON_CODEX_WEBSOCKET_RETRY_BUDGETNon-negative integer override (default 5)
VEYYON_CODEX_WEBSOCKET_RETRY_DELAY_MSPositive integer base backoff override (default 500)
VEYYON_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MSPositive 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_MSLongest quiet period a reused socket is trusted for before a fresh handshake (default 30000); 0 disables the ceiling
VEYYON_CODEX_WEBSOCKET_PING_INTERVAL_MSPositive integer keepalive ping interval (default 10000)
VEYYON_CODEX_WEBSOCKET_PONG_TIMEOUT_MSPositive integer wait for a pong before the socket is treated as dead (default 60000)
VEYYON_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITYPositive integer inbound frame queue capacity (default 4096)
VEYYON_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MSPositive integer OpenAI first-event timeout override; 0 disables. veyyon config set providers.streamFirstEventTimeoutSeconds <seconds> provides the persisted config equivalent
VEYYON_OPENAI_STREAM_IDLE_TIMEOUT_MSPositive integer OpenAI stream idle timeout override; 0 disables. veyyon config set providers.streamIdleTimeoutSeconds <seconds> provides the persisted config equivalent

Cursor provider debug

VariableBehavior
DEBUG_CURSOREnables provider debug logs; 2/verbose for detailed payload snippets
DEBUG_CURSOR_LOGOptional 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.

VariableBehavior
VEYYON_OPENROUTER_RESPONSESOpenRouter uses the Responses API unless this is exactly 0, which selects chat completions. Any other value leaves the default in place
VEYYON_PERPLEXITY_RESPONSESPerplexity web search uses the Responses API only when this is exactly 1

Prompt cache controls

VariableBehavior
VEYYON_CACHE_RETENTIONIf long, enables long retention where supported (anthropic, openai-responses, Bedrock retention resolution)
VEYYON_CACHE_ENFORCEMENTWhat 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

VariableUsed by
EXA_API_KEYExa search provider and Exa MCP tools
BRAVE_API_KEYBrave search provider
PERPLEXITY_API_KEYPerplexity search provider API-key mode
PERPLEXITY_COOKIESPerplexity cookie-auth search mode
TAVILY_API_KEYTavily search provider
ZAI_API_KEYz.ai search provider (also checks stored OAuth in agent.db)
OPENAI_API_KEY / Codex OAuth in DBCodex search provider availability/auth
VEYYON_CODEX_WEB_SEARCH_MODELCodex search provider model override
MOONSHOT_SEARCH_API_KEY / KIMI_SEARCH_API_KEYKimi/Moonshot search provider env auth
MOONSHOT_SEARCH_BASE_URL / KIMI_SEARCH_BASE_URLKimi/Moonshot search endpoint override
KAGI_API_KEYKagi search provider
JINA_API_KEYJina search provider
PARALLEL_API_KEYParallel search provider
SEARXNG_ENDPOINT, SEARXNG_TOKENSearXNG endpoint and optional bearer token
SEARXNG_BASIC_USERNAME, SEARXNG_BASIC_PASSWORDSearXNG 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:

  1. ANTHROPIC_SEARCH_API_KEY
  2. authStorage.getApiKey("anthropic") fallback credentials (runtime/config overrides, stored API-key credentials, stored OAuth credentials, then generic Anthropic env fallback: ANTHROPIC_FOUNDRY_API_KEY in Foundry mode, otherwise ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)

For either credential path, base URL resolution is:

  1. ANTHROPIC_SEARCH_BASE_URL
  2. FOUNDRY_BASE_URL when CLAUDE_CODE_USE_FOUNDRY is enabled
  3. ANTHROPIC_BASE_URL
  4. https://api.anthropic.com

Related vars:

VariableDefault / behavior
ANTHROPIC_SEARCH_API_KEYAPI 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_URLBase 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_MODELSearch model override. Defaults to claude-haiku-4-5.
ANTHROPIC_BASE_URLGeneric 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

VariableBehavior
VEYYON_AUTH_NO_BORROWIf set, disables macOS native-app token borrowing path in Perplexity login flow

4) Python tooling and kernel runtime

VariableDefault / behavior
VEYYON_PYBoolean-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_JSSame boolean-like override for the JavaScript eval backend; unset defers to the eval.js setting (default enabled)
VEYYON_PYTHON_SKIP_CHECKIf 1, skips Python interpreter availability checks (subprocess runner still starts on demand)
VEYYON_PYTHON_INTEGRATIONIf 1, opts gated integration tests in (e.g. python-runner-integration.test.ts) into running against real Python
VEYYON_PYTHON_IPC_TRACEIf 1, logs NDJSON frames exchanged with the Python runner subprocess
VEYYON_RUBY_IPC_TRACESame, for the Ruby runner subprocess
VEYYON_JULIA_IPC_TRACESame, for the Julia runner subprocess
VIRTUAL_ENVHighest-priority venv path for Python runtime resolution

Extra conditional behavior:

  • If BUN_ENV=test or NODE_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 in packages/coding-agent/src/eval/kernel-base.ts rather than from each kernel formatting its own name.

5) Agent/runtime behavior toggles

VariableDefault / behavior
VEYYON_SMOL_MODELEphemeral model-role override for smol (CLI --smol takes precedence)
VEYYON_STREAM_FRAME_MAX_BYTESBytes 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_MODELEphemeral model-role override for slow (CLI --slow takes precedence)
VEYYON_PLAN_MODELEphemeral model-role override for plan (CLI --plan takes precedence)
VEYYON_NO_TITLEIf set (any non-empty value), disables auto session title generation on first user message
VEYYON_SKIP_SETUPIf 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_DEVICEONNX 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_DTYPEONNX 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_THINKINGIf 1, disables Anthropic interleaved thinking budget behavior and uses output-token inflation for older thinking mode
VEYYON_NO_INTENTIf 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_PROMPTIf true, system prompt builder returns empty string
VEYYON_BLOCKED_AGENTSelf-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_CMDOverrides subagent spawn command (veyyon / veyyon.cmd resolution bypass)
VEYYON_TASK_MAX_OUTPUT_BYTESMax captured output bytes per subagent (default 500000)
VEYYON_TASK_MAX_OUTPUT_LINESMax captured output lines per subagent (default 5000)
VEYYON_TIMINGIf 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_STARTUPIf 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_DIROverrides package asset base dir resolution (docs, examples, and CHANGELOG assets)
VEYYON_REPAIR_DISABLEIf 1/true/yes, disables malformed-tool-call schema repair (calls fail instead of being repaired)
VEYYON_DISABLE_LSPMUXIf 1, disables lspmux detection/integration and forces direct LSP server spawning
VEYYON_RPC_EMIT_TITLEBoolean-like flag enabling title events in RPC mode
SMITHERY_URLSmithery web URL override (default https://smithery.ai)
SMITHERY_API_URLSmithery API base URL override (default https://api.smithery.ai)
SMITHERY_API_KEYSmithery API key for managed MCP auth lookup
PUPPETEER_EXECUTABLE_PATHBrowser tool Chromium executable override
LITELLM_BASE_URLLiteLLM proxy base URL fallback (http://localhost:4000/v1 if unset); an explicit baseUrl on the litellm provider in models.yml wins
LM_STUDIO_BASE_URLDefault implicit LM Studio discovery base URL override (http://127.0.0.1:1234/v1 if unset)
OLLAMA_BASE_URLDefault implicit Ollama discovery base URL override (OLLAMA_HOST if unset, then http://127.0.0.1:11434)
OLLAMA_HOSTOllama 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_LENGTHPositive 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_URLDefault implicit Llama.cpp discovery base URL override (http://127.0.0.1:8080 if unset)
VEYYON_EDIT_VARIANTForces edit tool variant when valid (patch, replace, hashline, apply_patch)
VEYYON_STRICT_EDIT_MODEIf 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_PROTOCOLForces supported image protocol (kitty, iterm2/iterm, sixel, none) where used
VEYYON_ALLOW_SIXEL_PASSTHROUGHAllows SIXEL passthrough when VEYYON_FORCE_IMAGE_PROTOCOL=sixel
VEYYON_NO_WEBPIf 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_PTYIf 1, disables interactive PTY path for bash tool
VEYYON_DIALECTForce-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_DEBUGDebug. 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_MSOverrides 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_MSHow 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_STACKIf 1, fatal CLI errors print the full inspected error (stack + source context) instead of the default concise message + cause chain report
VEYYON_EDIT_FUZZYtrue/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_THRESHOLDSimilarity 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_MSCross-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_MSCross-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_GUARDIf 1, disables the repeated-thinking-block loop detector for the models it normally guards
VEYYON_MAX_AST_FILESPositive integer cap on how many files one ast_edit call may rewrite (default 1000)
VEYYON_TOKENIZER_ACCURATEIf 1, uses the accurate tokenizer instead of the fast estimate. Ignored under NODE_ENV=test
VEYYON_REQ_DEBUGIf 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_PROXYEgress 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_SECTIONSBenchmark 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_STATEMENTSThe 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.

VariableDefault / behavior
VEYYON_CONFIG_DIRConfig root dirname under home (default .veyyon). A name, not a path: an absolute value is rejected at startup.
VEYYON_PROFILEActivate a named profile (relocates the user base to ~/.veyyon/profiles/<name>)
VEYYON_WORKTREE_DIRBase directory for task-isolation worktrees (default ~/.veyyon/profiles/<name>/wt)
VEYYON_GITHUB_CACHE_DBPath override for the GitHub tool cache database
VEYYON_AUTORESEARCH_DB_DIRDirectory override for the autoresearch database
VEYYON_CODING_AGENT_DIRFull override for the agent directory (default ~/<config dir>/profiles/<active-or-default>/agent)
PWDUsed 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.)

VariableBehavior
VEYYON_BASH_NO_CISuppresses automatic CI=true injection into spawned shell env
CLAUDE_BASH_NO_CILegacy alias fallback for VEYYON_BASH_NO_CI
VEYYON_BASH_NO_LOGINDisables login-shell mode; shell args become ['-c'] instead of ['-l','-c']
CLAUDE_BASH_NO_LOGINLegacy alias fallback for VEYYON_BASH_NO_LOGIN
VEYYON_SHELL_PREFIXOptional command prefix wrapper
CLAUDE_CODE_SHELL_PREFIXLegacy alias fallback for VEYYON_SHELL_PREFIX
VISUALPreferred external editor command
EDITORFallback 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.

VariableUsed for
COLORTERM, TERM, WT_SESSIONColor capability detection (theme color mode)
COLORFGBGTerminal background light/dark auto-detection
TERM_PROGRAM, TERM_PROGRAM_VERSION, TERMINAL_EMULATORTerminal identity in system prompt/context
TMUX_PANE, CMUX_SURFACE_ID, KITTY_WINDOW_ID, TERM_SESSION_ID, WT_SESSIONStable per-terminal session breadcrumb IDs
SHELL, ComSpec, TERM_PROGRAM, TERMSystem info diagnostics
APPDATA, XDG_CONFIG_HOMElspmux config path resolution
HOMEPath shortening in MCP command UI

9) TUI runtime flags (shared package, affects coding-agent UX)

VariableBehavior
VEYYON_NOTIFICATIONSoff / 0 / false suppress desktop notifications
VEYYON_TUI_WRITE_LOGIf set, logs TUI writes to file
VEYYON_HARDWARE_CURSORIf 1, enables hardware cursor mode
VEYYON_NO_SYNC_OUTPUTIf set (any non-empty value), disables DEC 2026 synchronized-output wrappers while keeping TUI autowrap guards
VEYYON_TUI_SYNC_OUTPUT0 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_OUTPUT1 forces synchronized output on, unless an opt-out is also set
VEYYON_TUI_SCROLL_TRANSPORTalt-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_DECCARAIf set (truthy), disables Kitty DECCARA rectangular-SGR background fills (forces padded-string rendering)
VEYYON_DEBUG_REDRAWIf 1, enables redraw debug logging
VEYYON_FORCE_IMAGE_PROTOCOLForces terminal image protocol detection (kitty, iterm2/iterm, sixel, none)
VEYYON_TUI_RESIZE_IN_PLACE1/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

VariableBehavior
VEYYON_COMMIT_TEST_FALLBACKIf true (case-insensitive), force commit fallback generation path
VEYYON_COMMIT_NO_FALLBACKIf true, disables fallback when agent returns no proposal
VEYYON_COMMIT_MAP_REDUCEIf false, disables map-reduce commit analysis path
DEBUGIf 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_CREDENTIALS path 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_CERTS when it points to private CA bundles)
  • VEYYON_REQ_DEBUG is not a secret itself, but the rr-session-*.json dumps 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, .env files, 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

ScopePathRead behaviorWrite behavior
Profile~/.veyyon/profiles/<name>/agent/config.ymlThe 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.jsonMigrated 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.ymlA 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 overlayAny file passed with --config <file>Loaded after the profile config, for that one process. Repeatable.Never persisted.
Runtime overridesIn-memory onlySet 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/.yaml path is requested and only a sibling .json exists, it is migrated to YAML automatically (idempotent, once per process).
  • .json and .jsonc configs 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 --config overlays.

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

CommandEffect
veyyon config listPrint 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 pathPrint the active agent directory (honors VEYYON_CODING_AGENT_DIR).
veyyon config init-xdgCreate 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.

TypeAccepted inputNotes
booleantrue, false, yes, no, on, off, 1, 0Case-insensitive. Anything else is rejected.
numberAny finite JavaScript numberInfinity/NaN are rejected.
enumOne of the key’s allowed valuesMust match exactly; the error lists the valid values.
arrayA JSON arraye.g. '["anthropic","openai"]'. Must parse and be an array.
recordA JSON objecte.g. '{"bash":"prompt"}'. Must parse and be a non-array object.
stringStored 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:

  1. 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.
  2. CLI config overlays: each --config <file>; later overlay files override earlier ones.
  3. Profile settings: ~/.veyyon/profiles/<name>/agent/config.yml.
  4. 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 varOverrides settingNotes
VEYYON_SMOL_MODELmodelRoles.smolAlso exposed as --smol.
VEYYON_SLOW_MODELmodelRoles.slowAlso exposed as --slow.
VEYYON_PLAN_MODELmodelRoles.planAlso exposed as --plan.
VEYYON_NO_PTY=1(disables PTY bash)Equivalent to --no-pty for the process.
VEYYON_PYeval.pyVEYYON_PY=0 disables the Python eval backend.
VEYYON_JSeval.jsVEYYON_JS=0 disables the JavaScript eval backend.
VEYYON_TINY_DEVICEproviders.tinyModelDeviceONNX execution provider for local tiny models.
VEYYON_TINY_DTYPEproviders.tinyModelDtypeONNX precision for local tiny models.
VEYYON_AUTH_BROKER_URLauth.broker.urlEnv value takes precedence over config.
VEYYON_AUTH_BROKER_TOKENauth.broker.tokenEnv 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:

  • --config overlays 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 (for enabledModels) or providers (for disabledProviders)
  • values or items (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 kindExample idsEffect
Model providersanthropic, openai, google, groq, ollama, openrouterRemoves those backends from model selection, even when credentials are available. See Providers.
Discovery sourcesnative, claude, codex, gemini, github, opencode, cursor, agents, agents-mdStops 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
KeyTypeDefaultNotes
modelRolesrecord{}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.
modelTagsrecord{}Custom role/tag metadata; can introduce additional roles.
modelProviderOrderarray[]Preferred provider order when a model id is ambiguous.
cycleOrderarray["smol","slow"]Roles cycled by the model switcher (app.model.cycleForward, often Ctrl+P). The entry default is dropped on load.
enabledModelsarray[]Allow-list of models; supports path-scoped entries. Empty means all available models.
disabledProvidersarray[]Disabled model/discovery providers; supports path-scoped entries. See above.
includeModelInPromptbooleanfalseInclude 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.

KeyTypeDefaultNotes
advisor.enabledbooleanfalseEnable the advisor runtime when modelRoles.advisor resolves to an available model.
advisor.subagentsbooleanfalseAlso enable advisor runtimes for spawned task/eval subagents.
advisor.syncBacklogenumoffBounded 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.immuneTurnsnumber3After 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:

  1. the current session’s choice, from /effort, /thinking, or the cycle keybinding
  2. an explicit :level on the selector a role resolved through, e.g. modelRoles.plan: anthropic/claude-opus-5:xhigh
  3. the defaultEffort row for the model about to run
  4. the defaultEffort * row
  5. 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
KeyTypeDefaultValues
defaultEffortrecord{}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.
defaultThinkingLevelenumhighRetired 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.
hideThinkingBlockbooleanfalseHide thinking blocks in output. --hide-thinking sets it for the run (display only).
thinkingBudgets.minimalnumber1024Token budget for the minimal level.
thinkingBudgets.lownumber2048Token budget for low.
thinkingBudgets.mediumnumber8192Token budget for medium.
thinkingBudgets.highnumber16384Token budget for high.
thinkingBudgets.xhighnumber32768Token budget for xhigh.
thinkingBudgets.maxnumber32768Token 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
KeyTypeDefaultNotes
temperaturenumber(unset)Sampling temperature. 0 is deterministic.
topPnumber(unset)Nucleus sampling.
topKnumber(unset)Top-K sampling.
minPnumber(unset)Minimum-probability cutoff.
presencePenaltynumber(unset)Presence penalty. Negative values, including -1, are sent as written.
repetitionPenaltynumber(unset)Repetition penalty. Values below 1 encourage repetition and are sent as written.
tier.openaienumnonenone, auto, default, flex, scale, priority. Sent as service_tier for OpenAI / OpenAI-Codex and OpenAI-family OpenRouter models.
tier.anthropicenumnonenone, priority. priority realizes fast mode on supported direct Claude models (ignored on Bedrock/Vertex and via OpenRouter).
tier.googleenumnonenone, flex, priority. Gemini API sends it in the body; Vertex sends priority via header (flex is a no-op on Vertex).
tier.subagentenuminheritinherit, none, auto, default, flex, scale, priority. Applied to the spawned model’s family; inherit tracks the main agent.
tier.advisorenumnoneinherit, none, auto, default, flex, scale, priority. Applied to the advisor model’s family.
personalitystringdefaultCommunication 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/*
KeyTypeDefaultNotes
retry.enabledbooleantrueRetry transient provider errors.
retry.maxRetriesnumber10Max retries per request.
retry.baseDelayMsnumber500Initial backoff.
retry.maxDelayMsnumber300000Backoff ceiling (5 min).
retry.modelFallbackbooleantrueFall back to another model when one is unavailable.
retry.fallbackChainsrecord{}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.fallbackRevertPolicyenumcooldown-expirycooldown-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
KeyTypeDefaultNotes
tools.approvalModeenumautoCanonical: 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-askask, write and auto-editask-command. Override per run with --approval-mode / --auto-approve / --yolo.
tools.approvalrecord{}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.discoveryModeenumautoauto, 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.essentialOverridearray[]Tool names kept available even when tools are narrowed.
tools.maxTimeoutnumber0Max tool runtime in seconds; 0 = no cap.
tools.intentTracingbooleantrueRecord per-call intent strings.
tools.outputMaxColumnsnumber768Per-line byte cap for streaming output; 0 disables.
tools.artifactSpillThresholdnumber50KB 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.artifactHeadBytesnumber20KB of head kept inline on spill, bounded with the tail by the threshold; 0 = tail-only.
tools.artifactTailBytesnumber20KB of tail kept inline on spill, bounded by the threshold.
tools.artifactTailLinesnumber500Max 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.

SettingThe question it answersDefault
subagent.enabledMay this session use subagents at all?true
subagent.delegationIs the model encouraged to fan work out, and how hard?preferred
subagent.agentsWhich 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 task tool is not built, so the model cannot spawn anything;
  • every delegation instruction leaves the system prompt;
  • subagent.delegation and subagent.agents are 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.

ValueBehavior
allowedThe tool is offered and the prompt does not request it. The model delegates when it judges that delegation helps.
preferredThe default. The prompt instructs the model to fan substantial work out rather than doing it alone.
requiredThe 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:

enabledMeaning
absentThe shipped default: the worker and every agent you wrote yourself are enabled, the bundled specialists are disabled.
trueEnabled. The agent is listed in the task tool description, and the model may choose it.
falseDisabled. 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:

CommandAgent it namesWorks with the agent disabled
/reviewrevieweryes

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:

  1. the agent’s own lane, subagent.agents.<name>.model, edited on that agent’s page.
  2. subagent.modelByDepth.<n>, for a spawn at exactly that depth.
  3. the agent definition’s own model: frontmatter, for an agent you wrote.
  4. 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:

ViewWhat it answers
LiveWhich 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.
CommsThe 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.

KeyTypeDefaultNotes
subagent.enabledbooleantrueThe master switch. false removes subagents entirely: no task tool, no delegation guidance. See above.
subagent.delegationenumpreferredallowed, preferred, required. How hard the prompt pushes; it never removes the ability to delegate. See above.
subagent.agentsrecord{}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.sharedModelbooleanfalseWhether 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.modelmodelChainunsetThe 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.modelByDepthrecord{}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.thinkingLevelstringunsetThe 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.batchbooleantrueBatch shape for the task tool: one call, many items.
subagent.maxConcurrencynumber32Subagents running at once.
subagent.maxNestedSpawnDepthnumber0Nested levels that subagents may spawn. Direct children receive no task tool at 0; an agent-specific override may raise the limit.
subagent.maxRuntimeMsnumber0Hard per-subagent wall-clock limit in ms; 0 disables it.
subagent.idleTtlMsnumber300000How 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.softRequestBudgetnumber200Requests after which a subagent is asked to wrap up; 0 disables the guard.
subagent.softRequestBudgetNoticebooleantrueInject that wrap-up notice once.
subagent.showResolvedModelBadgebooleantrueShow each subagent’s resolved model, and what decided it, on the task widget and the agent surfaces.
subagent.enableLspbooleanfalseLet subagents use the lsp tool.
subagent.isolation.modeenumnoneFilesystem isolation backend for subagents. See Safety.
subagent.isolation.mergeenumpatchHow isolated changes come back: patch or branch.
subagent.isolation.commitsenumgenericCommit 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
KeyTypeDefaultNotes
bash.enabledbooleantrueEnable the bash tool.
launch.enabledbooleantrueEnable the launch tool for shared long-running project processes.
bash.autoBackground.enabledbooleantrueAuto-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.thresholdMsnumber300000Max 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.enabledbooleanfalseWatch 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.stallMsnumber30000Idle time (no new output) before a bash call is treated as possibly stuck. Measures quiet output, not total run time.
eval.pybooleantruePython eval backend. VEYYON_PY=0 disables for the process.
eval.jsbooleantrueJavaScript eval backend. VEYYON_JS=0 disables for the process.
python.kernelModeenumsessionsession (persistent kernel) or per-call.
ruby.kernelModeenumsessionSame choice for Ruby cells: keep one kernel per session, or start and shut down a kernel for each cell.
julia.kernelModeenumsessionSame choice for Julia cells. A fresh Julia kernel recompiles, so per-call trades startup time for a clean slate.
python.interpreterstring""Path to a Python interpreter; empty = auto-detect.
lsp.enabledbooleanfalseStart language servers. Opt in; Files → LSP enters the nested switches. --no-lsp disables the whole stack for a run.
lsp.toolbooleantrueExpose the lsp tool to the agent. Independent of injected diagnostics.
lsp.lazybooleantrueStart servers on demand.
lsp.diagnosticsOnWritebooleantrueInject diagnostics after a write. Independent of the agent tool.
lsp.diagnosticsOnEditbooleanfalseInject diagnostics after an edit. Independent of the agent tool.
lsp.formatOnWritebooleanfalseFormat files on write.
lsp.diagnosticsDeduplicatebooleantrueCollapse duplicate diagnostics.
shellPathstring(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
KeyTypeDefaultNotes
edit.modeenumhashlineapply_patch, hashline, patch, replace.
edit.fuzzyMatchbooleantrueAllow fuzzy anchor matching.
edit.fuzzyThresholdnumber0.95Similarity threshold for fuzzy matching.
edit.blockAutoGeneratedbooleantrueRefuse to edit generated/lockfile-like files.
edit.streamingAbortbooleanfalseAbort on streaming edit mismatch.
edit.afterEditenumverifyverify runs one check when none followed the last edit, review reads back every file the turn changed, off neither.
read.defaultLimitnumber300Default line count for read without a selector; the window also stops at the tool output budget.
read.summarize.enabledbooleantrueStructural summaries for code reads.
read.summarize.prosebooleanfalseSummarize prose files too.
read.toolResultPreviewbooleanfalseInline preview of tool results.
readLineNumbersbooleanfalseShow 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
KeyTypeDefaultNotes
contextPromotion.enabledbooleanfalsePromote to a larger-context model on overflow instead of compacting.
compaction.enabledbooleantrueAutomatic conversation compaction.
compaction.midTurnEnabledbooleantrueCheck thresholds at safe mid-turn tool-loop boundaries before the next provider request.
compaction.strategyenumsummaryThe 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.modelmodelChainunsetModels 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.modelFallbackStrategyenumautoWhat 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.thresholdstringautoWhen 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.thresholdTokensnumber-1Retired, 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.thresholdPercentnumber-1Retired, 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.remoteEndpointstringunsetOptional 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.backendenumoffoff, local, hindsight, mnemopi. Each backend has its own hindsight.* / mnemopi.* / memories.* tuning keys.
autolearn.enabledbooleanfalseExperimental: 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.autoContinuebooleanfalseWhen autolearn.enabled, auto-run one capture turn at stop (uses extra tokens). Off = a passive reminder rides your next turn.
autolearn.minToolCallsnumber5Only nudge after a turn that used at least this many tools.
session.instrumentationenumoffHow 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
KeyTypeDefaultValues
theme.darkstringtitaniumTheme used on a dark terminal background.
theme.lightstringlightTheme used on a light terminal background.
symbolPresetenumunicodeunicode, nerd, ascii.
colorBlindModebooleanfalseUse blue instead of green for diff additions.
showHardwareCursorbooleantrueShow the terminal hardware cursor.
statusLine.enabledbooleantrueShow 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.presetenumdefaultdefault, minimal, compact, full, nerd, ascii, custom.
statusLine.separatorenumpipeRetired. 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.sessionAccentbooleantrueTint the editor border with the session color.
statusLine.transparentbooleantrueRetired, 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.showHookStatusbooleantrueShow hook status messages.
statusLine.showAccountbooleanfalseName 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.showImagesbooleantrueRender images inline (when the terminal supports it).
images.autoResizebooleantrueResize large images for model compatibility.
images.blockImagesbooleanfalseNever send images to providers.
tui.hyperlinksenumautooff, auto, always.
tui.scrollIsolationbooleanfalseMouse 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

KeyTypeDefaultValues
steeringModeenumone-at-a-timeall, one-at-a-time. How queued steering messages are delivered.
followUpModeenumone-at-a-timeall, one-at-a-time.
interruptModeenumimmediateimmediate, wait.
doubleEscapeActionenumtreebranch, tree, none.
autoResumebooleanfalseAuto-resume the most recent session in the cwd.
ask.timeoutnumber0Seconds 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.notifyenumonon, off.
session.workdirstringunsetPer-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
KeyTypeDefaultValues / notes
providers.webSearchenumautoauto 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.webSearchGeminiModelstring(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.imageenumautoauto, openai, antigravity, xai, gemini, openrouter.
providers.fetchenumautoauto, native, trafilatura, lynx, parallel, jina.
providers.tinyModelenumonlineonline or a local model (lfm2-350m, qwen3-0.6b, gemma-270m, qwen2.5-0.5b, lfm2-700m).
providers.tinyModelDeviceenumdefaultONNX execution provider for local tiny models. Overridden by VEYYON_TINY_DEVICE.
providers.tinyModelDtypeenumdefaultONNX precision for local tiny models. Overridden by VEYYON_TINY_DTYPE.
providers.openaiWebsocketsenumautoauto, off, on.
providers.openrouterVariantenumdefaultdefault, nitro, floor, online, exacto.
providers.kimiApiFormatenumanthropicopenai, anthropic.
provider.appendOnlyContextenumautoauto, on, off.
exa.enabledbooleantrueEnable Exa integration.
exa.enableSearchbooleantrueExa search.
exa.enableResearcherbooleanfalseExa researcher.
exa.enableWebsetsbooleanfalseExa websets.
searxng.endpointstring(unset)SearXNG instance URL.
searxng.tokenstring(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 asTypeDefaultValues / notes
defaultProfiledefaultProfilestringdefaultWhich 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.
profileSharingprofileSharingbooleantrueWhen 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.
authBrokerUrlauth: { 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.
authBrokerTokenauth: { 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:

  1. ~/.veyyon/profiles/default/agent/settings.json (renamed to settings.json.bak after a successful migration).
  2. 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):

OldNew
queueModesteeringMode
ask.timeout in milliseconds (value > 1000)seconds (divided by 1000), and the rewrite is logged with both values
flat theme: "<name>" stringtheme.dark / theme.light (slot chosen by luminance; built-in light/dark are dropped to use defaults)
task.isolation.enabled: true/falsesubagent.isolation.mode: auto/none
task.simpleremoved
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.enableLspthe same names under subagent.
task.agentIdleTtlMssubagent.idleTtlMs
task.isolation.*subagent.isolation.*
task.disabledAgentsone row per agent in subagent.agents
task.agentModelOverridesdropped, 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.tasksubagent.model (the task role is retired)
lastChangelogVersionmoved to a marker file and stripped from config.yml
collapseChangelogremoved; 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 disabledProviders array replacing your profile one.
  • Credentials can still come from environment variables, .env, OAuth, stored auth, or models.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

KeySettingTypeDefaultWhat it does
theme.darkDark ThemestringtitaniumTheme used when the terminal has a dark background.
theme.lightLight ThemestringlightTheme used when the terminal has a light background.
symbolPresetSymbol PresetenumunicodeGlyph set for icons and symbols (Unicode, Nerd Font, or ASCII). Values: unicode, nerd, ascii.
colorBlindModeColor-Blind ModebooleanfalseUse blue instead of green for diff additions.

Status Line

KeySettingTypeDefaultWhat it does
statusLine.enabledComposer FootlinebooleantrueShow 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.presetStatus Line PresetenumdefaultPre-built status line configurations. Values: default, minimal, compact, full, nerd, ascii, custom.
statusLine.sessionAccentSession AccentbooleantrueUse the session name color for the editor border. Shown under the tab’s Advanced fold.
statusLine.compactThinkingLevelCompact Thinking LevelbooleanfalseShow 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.showHookStatusShow Hook StatusbooleantrueDisplay hook status messages below the status line. Shown under the tab’s Advanced fold.
statusLine.showAccountShow Serving AccountbooleanfalseName 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

KeySettingTypeDefaultWhat it does
terminal.showImagesShow Inline ImagesbooleantrueRender images inline in the terminal.
images.autoResizeAuto-Resize ImagesbooleantrueResize large images to 2000x2000 max for better model compatibility. Shown under the tab’s Advanced fold.
terminal.showProgressNative Terminal ProgressbooleanfalseEmit OSC 9;4 indeterminate progress while the agent or context maintenance is running. Shown under the tab’s Advanced fold.
tui.textSizingLarge Headings (Kitty)booleanfalseRender 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.renderMermaidRender Mermaid DiagramsbooleantrueRender Mermaid fenced code blocks as ASCII diagrams. Shown under the tab’s Advanced fold.
tui.hyperlinksTerminal HyperlinksenumautoWrap 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.paintGroundPaint Theme GroundenumautoSet 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.tightTight LayoutbooleanfalseRemove the 1-character horizontal padding from the left and right of the terminal output. Shown under the tab’s Advanced fold.
tui.scrollbackRebuildRewrite ScrollbackbooleantrueErase 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.scrollIsolationScroll IsolationbooleanfalseRead 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.transitionsTransitionsenumonStructural motion: overlay open transitions and the moving rail beside a running tool. Values: on, off.
display.shimmerShimmerenumdisabledAnimation style for working/loading messages. Values: classic, kitt, living, disabled.
display.smoothStreamingSmooth StreamingbooleantrueReveal assistant text and streamed tool input smoothly while chunks arrive.
display.showTokenUsageShow Token UsagebooleanfalseShow what each turn spent under the assistant message: tokens, how long it took, and its rate.
display.cacheMissMarkerCache Miss MarkerbooleanfalseShow a divider above an assistant turn whose request lost (missed) the prompt cache. Shown under the tab’s Advanced fold.
display.toolOutputExpandedExpand Tool OutputbooleanfalseStart 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.
showHardwareCursorShow Hardware CursorbooleantrueShow terminal cursor for IME support. Shown under the tab’s Advanced fold.

Model

Compaction

KeySettingTypeDefaultWhat it does
display.collapseCompactedCollapse Compacted HistorybooleantrueCollapse 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.remoteRemote CompactionbooleantrueApplies 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.strategyCompaction TypeenumsummarySummary condenses history in place and continues the same session. Values: summary.
compaction.thresholdAuto-Compaction ThresholdstringautoWhen 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.modelCompaction ModelmodelChain(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.modelFallbackStrategyCompaction FallbackenumautoWhat 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.modelContextWindowCompaction Model Contextnumber(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

KeySettingTypeDefaultWhat it does
modelRolesRole Modelsrecord{}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

KeySettingTypeDefaultWhat it does
defaultEffortDefault Effortrecord{}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.
hideThinkingBlockHide Thinking BlocksbooleanfalseHide thinking blocks in assistant responses.
proseOnlyThinkingProse Only ThinkingbooleantrueOmit code blocks from thinking summaries and replace them with an ellipsis.
omitThinkingOmit Thinking summariesbooleanfalseInstruct upstream providers to completely omit thinking summaries from responses (where supported).
model.loopGuard.enabledLoop GuardbooleantrueEnable automatic stream loop detection for model reasoning and prose.
model.loopGuard.checkAssistantContentLoop Guard Scan ProsebooleantrueApply loop guard to assistant prose messages in addition to thinking logs.
model.loopGuard.toolCallReminderLoop Guard Tool-Call ReminderbooleantrueWhen 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.enabledTool-Call Loop GuardbooleantrueDetect consecutive identical tool calls across turns and inject a corrective steer.
model.toolCallLoopGuard.thresholdTool-Call Loop Thresholdnumber5Consecutive identical tool calls required before the corrective steer is injected.
model.toolCallLoopGuard.readSubsumptionThresholdRead Subsumption Loop Thresholdnumber3Consecutive fully-subsumed or redundant read calls on unchanged files before the corrective steer is injected.
model.toolCallLoopGuard.exemptToolsTool-Call Loop Exempt Toolsarray["job","irc"]Tool names that may repeat consecutively without triggering the cross-turn loop guard.
providers.autoThinkingModelAuto Thinking ModelenumonlineDifficulty 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

KeySettingTypeDefaultWhat it does
temperatureTemperaturenumber(unset)Sampling temperature. 0 is deterministic, 1 is creative. Unset lets the provider choose.
topPTop Pnumber(unset)Nucleus sampling cutoff, 0 to 1. Unset lets the provider choose.
topKTop Knumber(unset)Sample from the top K tokens. Unset lets the provider choose.
minPMin Pnumber(unset)Minimum probability threshold, 0 to 1. Unset lets the provider choose.
presencePenaltyPresence Penaltynumber(unset)Penalty for introducing tokens already present. Negative values encourage repetition; unset lets the provider choose.
repetitionPenaltyRepetition Penaltynumber(unset)Penalty for repeated tokens. Values below 1 encourage repetition; unset lets the provider choose.
textVerbosityText VerbosityenummediumOpenAI Responses and Codex response verbosity (low, medium, or high). Values: low, medium, high.
tier.openaiService Tier — OpenAIenumnoneHow 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.anthropicService Tier — AnthropicenumnoneHow 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.googleService Tier — GoogleenumnoneHow 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.subagentService Tier — SubagentenuminheritHow 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.advisorService Tier — AdvisorenumnoneHow 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

KeySettingTypeDefaultWhat it does
inlineToolDescriptorsInline Tool DescriptorsenumautoRender 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.
includeModelInPromptInclude Model in PromptbooleanfalseSurface 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.
includeWorkspaceTreeInclude Workspace TreebooleanfalseRender the workspace directory tree in the system prompt. WARNING: This can bust prompt caching across sessions when files are modified.
personalityPersonalitystringdefaultCommunication style rendered into the system prompt’s personality block. Extend via ~/.veyyon/personalities/<name>.md or project .veyyon/personalities/<name>.md.

Retry & Fallback

KeySettingTypeDefaultWhat it does
retry.maxRetriesRetry Attemptsnumber10Maximum retry attempts on API errors.
retry.maxDelayMsMax Retry Delaynumber300000Maximum 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.modelFallbackRetry Model FallbackbooleantrueAllow retry recovery to switch to configured fallback models.
retry.fallbackChainsRetry Fallback Chainsrecord{}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.perProviderPer-Provider Retryrecord{}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.fallbackRevertPolicyFallback Revert Policyenumcooldown-expiryWhen to return to the primary model after a fallback. Values: cooldown-expiry, never.
providers.anthropic.serverSideFallbackAnthropic Server-Side Fallback (Fable 5)booleanfalseWhen 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

KeySettingTypeDefaultWhat it does
advisor.enabledEnable AdvisorbooleanfalsePair a second model that passively reviews each turn and injects notes. Which model it runs is Advisor Model, directly below.
advisor.subagentsAdvisor for SubagentsbooleanfalseAlso enable the advisor on spawned task/eval subagents.
advisor.syncBacklogAdvisor Sync BacklogenumoffPause 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.immuneTurnsAdvisor Immune Turnsnumber3After an advisor concern or blocker interrupts, route further concerns/blockers non-interruptingly for this many primary turns.

Prewalk

KeySettingTypeDefaultWhat it does
prewalk.enabledEnable PrewalkbooleanfalsePlan 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.cheapModelPrewalk Cheap ModelmodelChain(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.strongModelPrewalk Strong ModelmodelChain(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

KeySettingTypeDefaultWhat it does
images.describeForTextModelsDescribe Images for Text ModelsbooleantrueWhen 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

KeySettingTypeDefaultWhat it does
steeringModeSteering Modeenumone-at-a-timeHow to process queued messages while agent is working. Values: all, one-at-a-time.
followUpModeFollow-Up Modeenumone-at-a-timeHow to drain follow-up messages after a turn completes. Values: all, one-at-a-time.
interruptModeInterrupt ModeenumimmediateWhen steering messages interrupt tool execution. Values: immediate, wait.
loop.modeLoop ModeenumpromptWhat happens between /loop iterations before re-submitting the prompt. Values: prompt, compact, reset.
doubleEscapeActionDouble-Escape ActionenumtreeAction when pressing Escape twice with empty editor. Values: branch, tree, none.
treeFilterModeSession Tree FilterenumdefaultDefault filter mode when opening the session tree. Values: default, no-tools, user-only, labeled-only, all.
autocompleteMaxVisibleAutocomplete Itemsnumber5Max visible items in autocomplete dropdown (3-20).
emojiAutocompleteEmoji AutocompletebooleantrueSuggest emojis from :name: shortcodes and expand text emoticons like :D or :-).
paste.largeMenuThresholdLarge Paste Menunumber100When 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

KeySettingTypeDefaultWhat it does
session.newKeepsBackground/new Keeps The Old SessionbooleanfalseRequires 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

KeySettingTypeDefaultWhat it does
tools.approvalTool Approval Policiesrecord{}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.protectedPathsExtra Protected Pathsarray[]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.approvalModeTool ApprovalenumautoHow 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

KeySettingTypeDefaultWhat it does
completion.notifyCompletion NotificationenumoffNotify when the agent finishes a turn (off by default: the turn is on your screen). Values: on, off.
ask.timeoutAsk Timeoutnumber0Auto-select the recommended ask option after this many seconds (0 disables).
ask.notifyAsk NotificationenumonNotify when the agent is blocked on a question you have not answered. Values: on, off.
recap.enabledIdle RecapbooleantrueGenerate a brief LLM recap of where things stand after the terminal has been idle.
recap.idleSecondsIdle Recap Delaynumber240Seconds to wait while idle before showing the recap.

Speech

KeySettingTypeDefaultWhat it does
stt.enabledSpeech-to-TextbooleanfalseEnable speech-to-text input via microphone.
stt.modelNameSpeech ModelenumparakeetLocal 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.submitTriggerSpeech-to-Text Submit TriggerenumneverChoose 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

KeySettingTypeDefaultWhat it does
collab.relayUrlRelay URLstringwss://share.veyyon.devRelay used by /collab (wss://host[:port]).
collab.webUrlWeb UI URLstring(empty)Browser UI used by /collab links; empty derives from collab.relayUrl; explicit http:// is localhost-only.
collab.displayNameDisplay Namestring(empty)Name shown to other collab participants (default: OS username).
share.serverUrlShare Serverstringhttps://share.veyyon.dev/sShare viewer/upload base used by /share (encrypted blob upload + viewer; links are <base>/<id>#<key>).
share.storeShare StoreenumblobWhere /share uploads the encrypted session blob. Values: blob, gist.
share.redactSecretsShare Secret RedactionbooleantrueRun the secret obfuscator over /share snapshots before upload (uses the secrets.* config).

Magic Keywords

KeySettingTypeDefaultWhat it does
magicKeywords.enabledEnable Magic KeywordsbooleantrueEnable hidden notices for standalone ultrathink, orchestratez, and workflowz keywords.
magicKeywords.ultrathinkUltrathink KeywordbooleantrueLet standalone ultrathink request maximum automatic thinking and append its hidden notice.
magicKeywords.orchestrateOrchestrate KeywordbooleantrueLet standalone orchestratez append its hidden multi-agent orchestration notice.
magicKeywords.workflowWorkflow KeywordbooleantrueLet standalone workflowz append its hidden eval workflow notice.
magicKeywords.turnBudgetTurn Budget DirectivebooleanfalseLet 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

KeySettingTypeDefaultWhat it does
autoResumeAuto ResumebooleanfalseAutomatically resume the most recent session in the current directory.
startup.quietQuiet StartupbooleanfalseSkip welcome screen and startup status messages.
startup.showSplashShow Startup SplashbooleanfalseShow the full animated setup splash on normal interactive startup without rerunning setup. Quiet Startup still suppresses it.
startup.clearScrollbackClear Scrollback on StartupbooleanfalseErase 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.setupWizardSetup WizardbooleantrueRun onboarding on first install only (updates never re-run it).
startup.checkUpdateCheck for UpdatesbooleantrueCheck for Veyyon updates on startup.
marketplace.autoUpdateMarketplace Auto-UpdateenumnotifyCheck for plugin updates on startup. Values: off, notify, auto.
startup.autoUpdateAutomatic UpdatesbooleantrueInstall a newer version in the background; off means updates only when you run veyyon update.
startup.updateNoticeUpdate NoticebooleantrueShow a one-line notice on the first launch after an update.

Profile

KeySettingTypeDefaultWhat it does
profile.displayNameProfile Namestring(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.workdirDefault Working Directorystring(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)

KeySettingTypeDefaultWhat it does
power.sleepPreventionSleep PreventionenumidlePrevent macOS sleep during active sessions. Each level is cumulative — it adds the flags of all lower levels. Values: off, idle, display, system.

Agent

KeySettingTypeDefaultWhat it does
features.unexpectedStopDetectionDetect unexpected stopsbooleanfalseUse a small model to detect when the assistant says it will continue but stops without tool calls; automatically prompt it to continue.

Git

KeySettingTypeDefaultWhat it does
git.enabledEnable Git IntegrationbooleantrueShow git branch, status, and PR information in the TUI and watch repository metadata.

Resources

CPU

KeySettingTypeDefaultWhat it does
session.cpuLimitCoresSession CPU Limitnumber0Maximum 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.cpuLimitKillKill Over-Budget CommandsbooleanfalseWhat 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

KeySettingTypeDefaultWhat it does
session.memoryLimitGbSession Memory Limitnumber0Maximum 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

KeySettingTypeDefaultWhat it does
session.writeBudgetGbSession Write Budgetnumber0Cumulative 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.writeBudgetKillKill Over-Budget WritersbooleanfalseWhat 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

KeySettingTypeDefaultWhat it does
session.maxProcessesSession Max Processesnumber0Hard 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

KeySettingTypeDefaultWhat it does
contextPromotion.enabledAuto-Promote ContextbooleanfalsePromote to a larger-context model on context overflow instead of compacting.
branchSummary.enabledBranch SummariesbooleanfalsePrompt to summarize when leaving a branch.
context.thinkingRetentionThinking Retentionnumber-1How 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.thoughtSignatureRetentionThought Signature Retentionnumber-1How 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.thoughtSignatureMaxLengthThought Signature Size Limitnumber-1Longest 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

KeySettingTypeDefaultWhat it does
cache.reportRejectionReport Cache RejectionsbooleantrueWarn 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.blockOnRejectionBlock On Cache RejectionbooleanfalseAnthropic 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

KeySettingTypeDefaultWhat it does
session.instrumentationInstrumentation LevelenumoffRecord 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

KeySettingTypeDefaultWhat it does
ttsr.builtinRulesBuilt-in RulesbooleantrueLoad the default rules shipped with the agent. Turn individual rules off under All Rules.
ttsr.disabledRulesAll Rulesarray[]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)

KeySettingTypeDefaultWhat it does
ttsr.enabledTTSRbooleantrueInterrupt the agent mid-stream when output matches rule patterns (Time-Traveling Stream Rules).
ttsr.contextModeContext ModeenumdiscardWhat to do with partial output when TTSR triggers. Values: discard, keep.
ttsr.interruptModeRule Interrupt ModeenumalwaysWhen to interrupt mid-stream vs inject warning after completion. Values: never, prose-only, tool-only, always.
ttsr.repeatModeRepeat ModeenumonceHow rules can repeat: once per session or after a message gap. A rule may override this in its frontmatter. Values: once, after-gap.
ttsr.repeatGapRepeat Gapnumber10Messages before a rule can trigger again. A rule may override this in its frontmatter.

Memory

General

KeySettingTypeDefaultWhat it does
memory.backendMemory BackendenumoffOff, local summary pipeline, Mnemopi SQLite, or Hindsight remote memory. Values: off, local, hindsight, mnemopi.
providers.memoryModelMemory ModelenumonlineMnemopi 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

KeySettingTypeDefaultWhat it does
mnemopi.dbPathMnemopi DB Pathstring(unset)Optional SQLite DB path. Defaults to the agent memories directory.
mnemopi.bankMnemopi Bankstring(unset)Optional shared bank base name. Per-project modes derive project-local banks from it.
mnemopi.scopingMnemopi Scopingenumper-projectglobal = 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.embeddingVariantEmbedding variantenumenLocal embedding model family. en = stronger English model; multilingual = cross-language model. Changing this rebuilds existing memory embeddings on next start. Values: en, multilingual.
mnemopi.autoRecallMnemopi Auto RecallbooleantrueRecall local memories into the first turn of each session.
mnemopi.autoRetainMnemopi Auto RetainbooleantrueRetain completed conversation turns into local Mnemopi memory.
mnemopi.polyphonicRecallMnemopi Polyphonic RecallbooleanfalseEnable 4-voice recall (vector, graph, fact, temporal) fused with reciprocal rank fusion.
mnemopi.enhancedRecallMnemopi Enhanced RecallbooleanfalseEnable the tiered query result cache for repeated and similar recall queries.
mnemopi.proactiveLinkingMnemopi Proactive LinkingbooleanfalseIngest new memories into the episodic graph as they are stored, linking them to related entities and memories.
mnemopi.noEmbeddingsMnemopi Disable EmbeddingsbooleanfalseForce deterministic FTS-only recall instead of vector embeddings.
mnemopi.embeddingModelMnemopi Embedding Modelstring(unset)Advanced: explicit embedding model id that overrides the variant. Leave empty to use mnemopi.embeddingVariant.
mnemopi.embeddingApiUrlMnemopi Embedding API URLstring(unset)Optional OpenAI-compatible embedding endpoint passed to Mnemopi.
mnemopi.embeddingApiKeyMnemopi Embedding API Keystring(unset)Optional embedding API key passed to Mnemopi.
mnemopi.llmModeMnemopi LLM ModeenumsmolUse no LLM, the online tiny model (the TINY role from /models, else @smol), or a remote OpenAI-compatible endpoint. Values: none, smol, remote.
mnemopi.llmBaseUrlMnemopi LLM Base URLstring(unset)Optional OpenAI-compatible LLM endpoint for Mnemopi remote mode.
mnemopi.llmApiKeyMnemopi LLM API Keystring(unset)Optional LLM API key for Mnemopi remote mode.
mnemopi.llmModelMnemopi LLM Modelstring(unset)Optional LLM model name for Mnemopi remote mode.

Hindsight

KeySettingTypeDefaultWhat it does
hindsight.apiUrlHindsight API URLstringhttp://localhost:8888Hindsight server URL (Cloud or self-hosted).
hindsight.bankIdHindsight Bank IDstring(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.scopingHindsight Scopingenumper-project-taggedglobal = 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.autoRecallHindsight Auto RecallbooleantrueRecall memories on the first turn of each session.
hindsight.autoRetainHindsight Auto RetainbooleantrueRetain transcript every N turns and at session boundaries.
hindsight.retainModeHindsight Retain Modeenumfull-sessionfull-session = upsert one document per session, last-turn = chunked. Values: full-session, last-turn.
hindsight.mentalModelsEnabledHindsight Mental ModelsbooleantrueRead 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.mentalModelAutoSeedHindsight Mental Model Auto-SeedbooleantrueAt session start, create any built-in mental models (project-conventions, project-decisions, user-preferences) that do not yet exist on the bank.

Files

Editing

KeySettingTypeDefaultWhat it does
edit.modeEdit ModeenumhashlineSelect the edit tool variant (replace, patch, hashline, or apply_patch). Values: apply_patch, hashline, patch, replace.
edit.fuzzyMatchFuzzy MatchbooleantrueAccept high-confidence fuzzy matches for whitespace differences.
edit.fuzzyThresholdFuzzy Match Thresholdnumber0.95Similarity threshold (0-1) for accepting fuzzy matches.
edit.streamingAbortAbort on Failed PreviewbooleanfalseAbort streaming edit tool calls when patch preview fails.
edit.blockAutoGeneratedBlock Auto-Generated FilesbooleantruePrevent editing of files that appear to be auto-generated (protoc, sqlc, swagger, etc.).
edit.afterEditAfter an EditenumverifyWhat 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

KeySettingTypeDefaultWhat it does
readLineNumbersLine NumbersbooleanfalsePrepend line numbers to read tool output by default.
read.defaultLimitDefault Read Limitnumber300Line 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.toolResultPreviewInline Read PreviewsbooleanfalseRender read tool results inline in the transcript instead of summary rows.

Read Summaries

KeySettingTypeDefaultWhat it does
read.summarize.enabledEnable Read SummariesbooleantrueReturn structural code summaries when read is called without an explicit selector.
read.summarize.proseProse SummariesbooleanfalseReturn structural summaries for Markdown and plain text reads.
read.summarize.minBodyLinesRead Summary Body Linesnumber4Minimum multiline body or literal length before read summaries collapse it.
read.summarize.minCommentLinesRead Summary Comment Linesnumber6Minimum multiline block comment length before read summaries collapse it.
read.summarize.minTotalLinesRead Summary Minimum File Lengthnumber100Files with fewer total lines are read verbatim instead of structurally summarized.
read.summarize.unfoldUntilRead Summary Unfold Targetnumber50BFS-unfold elidable spans until the summary is at least this many visible lines. 0 keeps only the outermost elisions.
read.summarize.unfoldLimitRead Summary Unfold Ceilingnumber100Hard 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

KeySettingTypeDefaultWhat it does
lsp.enabledLanguage ServersbooleanfalseStart 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.toolAgent ToolbooleantrueGive the agent the lsp tool (definitions, references, rename, query diagnostics). Off keeps servers for format and injected diagnostics only.
lsp.lazyLazy StartupbooleantrueStart language servers on first use (lsp tool or editing a matching file type) instead of at session startup.
lsp.formatOnWriteFormat after WritebooleanfalseFormat the file with the language server after the write tool saves it. Independent of the agent tool and of diagnostics.
lsp.diagnosticsOnWriteDiagnostics after WritebooleantrueAfter the write tool saves a file, inject language-server diagnostics into the session. Independent of the agent tool.
lsp.diagnosticsOnEditDiagnostics after EditbooleanfalseAfter the edit tool saves a file, inject language-server diagnostics into the session. Independent of the agent tool.
lsp.diagnosticsDeduplicateDeduplicate DiagnosticsbooleantrueSuppress post-edit LSP diagnostics already shown for a file; only surface new or changed ones.

Shell

Bash

KeySettingTypeDefaultWhat it does
bash.enabledEnable BashbooleantrueEnable the bash tool for shell command execution.
bash.stallDetection.enabledBash Stall DetectionbooleanfalseWatch 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.enabledBash InterceptorbooleanfalseBlock shell commands that have dedicated tools.
shellMinimizer.enabledShell MinimizerbooleantrueCompress verbose shell output (git, npm, cargo, etc.) before returning it to the agent.
shellMinimizer.sourceOutlineLevelShell Minimizer Source OutlineenumdefaultSource outline mode for cat/read of source files: default or aggressive. Values: default, aggressive.
bash.autoBackground.enabledBash Auto-BackgroundbooleantrueMove 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.thresholdMsAuto-Background Afternumber300000Max 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.stallMsStall Afternumber30000When 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

KeySettingTypeDefaultWhat it does
eval.pyPython Eval BackendbooleantrueAllow the eval tool to dispatch Python cells to the IPython kernel.
eval.jsJavaScript Eval BackendbooleantrueAllow the eval tool to dispatch JavaScript cells to the in-process runtime.
eval.rbRuby Eval BackendbooleanfalseAllow the eval tool to dispatch Ruby cells to the persistent Ruby kernel.
eval.jlJulia Eval BackendbooleanfalseAllow the eval tool to dispatch Julia cells to the persistent Julia kernel.
ruby.kernelModeRuby Kernel ModeenumsessionKeep the Ruby kernel alive across eval calls or start fresh each time. Values: session, per-call.
julia.kernelModeJulia Kernel ModeenumsessionKeep the Julia kernel alive across eval calls or start fresh each time. Values: session, per-call.
python.kernelModePython Kernel ModeenumsessionKeep the IPython kernel alive across eval calls or start fresh each time. Values: session, per-call.
python.interpreterPython Interpreterstring(empty)Optional path to an exact Python executable. When set, automatic Python runtime discovery is skipped.
ruby.interpreterRuby Interpreterstring(empty)Optional path to an exact Ruby executable. When set, automatic Ruby runtime discovery is skipped.
julia.interpreterJulia Interpreterstring(empty)Optional path to an exact Julia executable. When set, automatic Julia runtime discovery is skipped.

Tools

Available Tools

KeySettingTypeDefaultWhat it does
todo.enabledTodosbooleantrueEnable the todo tool for task tracking.
astEdit.enabledAST EditbooleantrueEnable the ast_edit tool for structural AST rewrites.
debug.enabledDebugbooleantrueEnable the debug tool for DAP-based debugging. The tool loads only where a configured adapter command resolves.
launch.enabledLaunchbooleantrueEnable the launch tool for supervising shared long-running project processes.
speechgen.enabledSpeech GenerationbooleanfalseEnable the tts tool for on-device (Kokoro) or xAI Grok Voice speech-file synthesis.
generate_image.enabledGenerate ImagebooleanfalseEnable the generate_image tool for text-to-image generation and editing.
inspect_image.enabledInspect ImagebooleanfalseEnable the inspect_image tool, delegating image understanding to a vision-capable model.
checkpoint.enabledCheckpoint/RewindbooleanfalseEnable the checkpoint and rewind tools for context checkpointing.
fetch.enabledRead URLsbooleantrueAllow the read tool to fetch and process URLs.
vault.enabledObsidian VaultbooleanfalseEnable 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.enabledGitHub CLIbooleanfalseEnable the github tool (op-based dispatch for repository, issue, pull request, diff, search, checkout, push, and Actions watch workflows).
web_search.enabledWeb SearchbooleantrueEnable the web_search tool for live web results.
ask.enabledAskbooleantrueEnable the ask tool for interactive user questions.
browser.enabledBrowserbooleanfalseEnable the browser tool for scripted Chromium automation (puppeteer).

Todos

KeySettingTypeDefaultWhat it does
todo.remindersTodo RemindersbooleantruePrompt continued execution when unfinished todos remain.
todo.reminders.maxTodo Reminder Limitnumber3Maximum distinct todo-state reminders before reminders stay silent.
todo.eagerCreate Todos AutomaticallyenumdefaultHow strongly to push automatic todo-list creation after the first message. Values: default, preferred, always.
tasks.todoClearDelayTodo Auto-Clear Delaynumber-1Delay before completed or abandoned todos are removed from the todo widget.

Launch

KeySettingTypeDefaultWhat it does
launch.cleanupWaitMsLaunch Cleanup Waitnumber900000How long an exited process record is retained before being purged from memory and disk (0 = never clean up).

Search Context

KeySettingTypeDefaultWhat it does
search.contextBeforeText Context Beforenumber1Lines of context before each text search match.
search.contextAfterText Context Afternumber1Lines of context after each text search match.

Browser

KeySettingTypeDefaultWhat it does
browser.headlessHeadless BrowserbooleantrueLaunch browser in headless mode (disable to show browser UI).
browser.cmuxcmux BrowserbooleantrueUse 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.screenshotDirScreenshot Directorystring(unset)Directory to save screenshots. If unset, screenshots go to a temp file. Supports ~. Examples: ~/Downloads, ~/Desktop, /sdcard/Download (Android).

GitHub

KeySettingTypeDefaultWhat it does
github.cache.enabledGitHub View CachebooleantrueCache rendered issue/PR view output in the active profile’s cache/github-cache.db so repeated reads are free.
github.cache.softTtlSecGitHub Cache Soft TTLnumber300Within this window, cached issue/PR view rows are returned directly (seconds; default 5 minutes).
github.cache.hardTtlSecGitHub Cache Hard TTLnumber604800Past 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

KeySettingTypeDefaultWhat it does
tools.artifactSpillThresholdArtifact Spill Threshold (KB)number50Tool 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.artifactTailBytesArtifact Tail Size (KB)number20Amount of tail content kept inline when output spills to artifact, bounded by the spill threshold.
tools.artifactHeadBytesArtifact Head Size (KB)number20Amount 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.outputMaxColumnsOutput Column Capnumber768Per-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.artifactTailLinesArtifact Tail Linesnumber500Maximum lines of tail content kept inline when output spills to artifact.
tools.inlineOutputFloorInline Output Floornumber0.25Smallest 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

KeySettingTypeDefaultWhat it does
tools.intentTracingIntent TracingbooleantrueAsk the agent to describe the intent of each tool call before executing it.
tools.abortOnFabricatedResultAbort On Fabricated Tool ResultbooleantrueWith 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.maxTimeoutMax Tool Timeoutnumber0Maximum timeout in seconds the agent can set for any tool (0 = no limit).
async.enabledAsync ExecutionbooleantrueEnable async bash commands and background task execution.
async.pollWaitDurationMax Poll TimeenumsmartHow 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

KeySettingTypeDefaultWhat it does
tools.discoveryModeTool DiscoveryenumautoHide 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.essentialOverrideEssential Tools Overridearray[]Override the always-loaded built-in tools (default: read, bash, launch, edit, write, search, eval). Leave empty to use defaults.
mcp.discoveryModeMCP Tool DiscoverybooleanfalseHide MCP tools by default and expose them through a tool discovery tool.
mcp.discoveryDefaultServersMCP Discovery Default Serversarray[]Keep MCP tools from these servers visible while discovery mode hides other MCP tools.
mcp.notificationsMCP Update InjectionbooleanfalseInject MCP resource updates into the agent conversation.
mcp.notificationDebounceMsMCP Notification Debouncenumber500Debounce window in milliseconds for MCP resource updates before injecting them into the conversation.

Developer

KeySettingTypeDefaultWhat it does
dev.autoqaAuto QAbooleanfalseRecord unexpected built-in tool behavior in this profile’s local grievance database.
dev.autoqaPush.enabledAuto-upload GrievancesbooleanfalseSend new and queued grievances to veyyon.dev after recording them. Off keeps reports local until you run veyyon grievances push.
dev.autoqaPush.endpointGrievance Upload Endpointstringhttps://veyyon.dev/api/grievancesDestination for automatic and manual grievance uploads.

Tasks

Modes

KeySettingTypeDefaultWhat it does
plan.enabledPlan ModebooleantrueEnable plan mode for read-only exploration and planning before execution.
plan.defaultOnStartupStart in Plan ModebooleanfalseAutomatically enter plan mode at the start of every new session.
goal.enabledGoal ModebooleantrueEnable per-session goal mode and the hidden goal tool.
goal.modelBudgetsEnabledModel Goal BudgetsbooleanfalseExpose and enforce persisted per-goal token budgets for the model. This control is available only in Settings.
goal.statusInFooterGoal Progress Bar in FooterbooleantrueAdd 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.continuationModesGoal Continuation Modesarray["interactive"]Run modes where active goals may auto-continue between turns.
title.refreshOnReplanRefresh Title on ReplanbooleantrueRefresh generated session titles after todo init replans unless the title was set by the user.

Commands & Skills

KeySettingTypeDefaultWhat it does
skills.enableSkillCommandsSkill CommandsbooleantrueRegister skills as /skill:name commands.
commands.enableClaudeUserClaude User CommandsbooleantrueLoad commands from ~/.claude/commands/.
commands.enableOpencodeUserOpenCode User CommandsbooleantrueLoad commands from ~/.config/opencode/commands/.

Subagents

Delegation

KeySettingTypeDefaultWhat it does
subagent.enabledSubagentsbooleantrueWhether 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.delegationSubagent DelegationenumpreferredHow 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.batchBatch Task CallsbooleantrueSwitch 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

KeySettingTypeDefaultWhat it does
subagent.agentsRosterrecord{}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.maxNestedSpawnDepthMax Nested Spawn Depthnumber0How 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.sharedModelSame Model for All SubagentsbooleanfalseRun 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.modelShared ModelmodelChain(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.thinkingLevelShared Effortstring(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.showResolvedModelBadgeShow Resolved Model BadgebooleantrueShow 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

KeySettingTypeDefaultWhat it does
subagent.maxConcurrencyMax Concurrent Subagentsnumber32Maximum number of subagents running concurrently.
subagent.maxRuntimeMsMax Subagent Runtimenumber0Hard 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.softRequestBudgetSoft Request Budgetnumber200Soft 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.softRequestBudgetNoticeSoft Request Budget NoticebooleantrueInject one steering notice when a subagent crosses its soft request budget, asking it to wrap up before the 1.5x forced-yield stop.
subagent.enableLspLSP in SubagentsbooleanfalseAllow 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

KeySettingTypeDefaultWhat it does
subagent.idleTtlMsPark Afternumber300000Stage 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

KeySettingTypeDefaultWhat it does
subagent.prune.enabledPrune Parked SubagentsbooleantrueStage 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.afterMsPrune Afternumber3600000How 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.waitingAfterMsPrune After While Waitingnumber7200000The 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

KeySettingTypeDefaultWhat it does
subagent.isolation.modeIsolation ModeenumnoneIsolation 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.mergeIsolation Merge StrategyenumpatchHow isolated subagent changes are integrated (patch apply or branch merge). Values: patch, branch.
subagent.isolation.commitsIsolation Commit StyleenumgenericCommit message style for nested repo changes (generic or AI-generated). Values: generic, ai.
worktree.baseWorktree Base Directorystring(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

KeySettingTypeDefaultWhat it does
irc.timeoutMsIRC Timeoutnumber120000Default 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

KeySettingTypeDefaultWhat it does
accounts.loadBalancingAccount Load BalancingbooleanfalseOff: 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

KeySettingTypeDefaultWhat it does
providers.maxInFlightRequestsMax In-Flight Requestsrecord{}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.maxConcurrencyOllama Cloud Max Concurrencynumber3Maximum concurrent Ollama Cloud subagent runs per process; 0 disables the provider-specific limit.
providers.webSearchWeb Search ProviderenumautoThe 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.webSearchExcludeExcluded Web Search Providersarray[]Providers that web_search should never use, even as fallbacks.
providers.webSearchGeminiModelGemini web_search modelstring(unset)Model ID for Gemini Google Search grounding. Defaults to gemini-2.5-flash.
providers.antigravityEndpointAntigravity Endpoint ModeenumautoEndpoint routing strategy for google-antigravity providers (chat, search, image, discovery). Values: auto, production, sandbox.
providers.imageImage ProviderenumautoPreferred provider for image generation. Values: auto, openai, antigravity, xai, gemini, openrouter.
providers.ttsText-to-Speech ProviderenumautoBackend for the tts tool: local on-device neural TTS (Kokoro-82M) or xAI Grok Voice. Values: auto, local, xai.
tts.localModelLocal TTS ModelenumkokoroOn-device neural TTS model (Kokoro-82M) used by the local TTS backend. Values: kokoro.
tts.localVoiceLocal TTS Voiceenumaf_heartKokoro 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.enabledSpeech VocalizationbooleanfalseSpeak the assistant’s output aloud through the speakers as it streams.
speech.modeSpeech Vocalization ModeenumassistantWhat to speak: all = assistant messages + thinking; assistant = messages only; yield = only the final message at turn end. Values: all, assistant, yield.
speech.enhancedEnhanced Speech RewritingbooleanfalseRewrite 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.voiceSpeech Vocalization Voiceenumaf_heartKokoro 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.fetchFetch ProviderenumautoReader backend priority for the fetch/read URL tool. Values: auto, native, trafilatura, lynx, parallel, jina.
codexResets.autoRedeemCodex Auto-Redeem Saved ResetsenumunsetWhen 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.minBlockedMinutesCodex Auto-Redeem Min Blocknumber60Only 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.keepCreditsCodex Auto-Redeem Reservenumber0Never auto-spend below this many saved resets (0 = the last credit may be spent automatically).
exa.enabledExabooleantrueMaster toggle for all Exa search tools.
exa.enableSearchExa SearchbooleantrueEnable Exa basic search, deep search, code search, and crawl tools.
exa.searchDelayMsExa Search Delaynumber1000Minimum delay between Exa web search requests in milliseconds; set 0 to disable pacing.
exa.enableResearcherExa ResearcherbooleanfalseEnable the Exa researcher tool for AI-powered deep research.
exa.enableWebsetsExa WebsetsbooleanfalseEnable Exa webset management and enrichment tools.
searxng.endpointSearXNG Endpointstring(unset)Base URL of a self-hosted SearXNG instance used for web search.

Discovery

KeySettingTypeDefaultWhat it does
discovery.importForeignConfigImport Other Tools’ ConfigbooleanfalseAuto-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

KeySettingTypeDefaultWhat it does
providers.fireworksTierFireworks TierenumstandardServing 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

KeySettingTypeDefaultWhat it does
providers.tinyModelSession Title ModelenumonlineSession-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.tinyModelDeviceTiny Model DeviceenumdefaultONNX 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.tinyModelDtypeTiny Model PrecisionenumdefaultONNX 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.unexpectedStopModelUnexpected Stop ModelenumonlineClassifier 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

KeySettingTypeDefaultWhat it does
providers.kimiApiFormatKimi API FormatenumanthropicAPI format for Kimi Code provider. Values: openai, anthropic.
providers.openaiWebsocketsOpenAI WebSocketsenumautoWebsocket policy for OpenAI Codex models (auto uses model defaults, on forces, off disables). Values: auto, off, on.
providers.openrouterVariantOpenRouter RoutingenumdefaultDefault routing-variant suffix appended to OpenRouter model IDs (overridden when the selector already names a variant). Values: default, nitro, floor, online, exacto.
provider.appendOnlyContextAppend-Only ContextenumautoCache 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

KeySettingTypeDefaultWhat it does
providers.streamFirstEventTimeoutSecondsStream First Event Timeoutnumber-1Seconds to wait for the first model stream event; -1 uses provider/env defaults, 0 disables the watchdog.
providers.streamIdleTimeoutSecondsStream Idle Timeoutnumber-1Seconds a model stream may stay silent between events; -1 uses provider/env defaults, 0 disables the watchdog.

Privacy

KeySettingTypeDefaultWhat it does
images.blockImagesBlock ImagesbooleanfalsePrevent images from being sent to LLM providers.
secrets.enabledHide SecretsbooleanfalseObfuscate secrets before sending to AI providers. Storing a credential with /secret turns this on for you.
secrets.defaultTtlSecret Lifetimestring1dHow long a /secret lasts when the command does not say. Default 1d; also accepts forms like 30m, 12h, 7d, 2w, or “never”.
secrets.auditLogRecord Secret UsebooleantrueAppend which secret was used in which command to the profile’s log. Never records values.
secrets.expiryWarningsWarn Before A Secret ExpiresbooleantrueSay 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

KeySettingTypeDefaultWhat it does
argot.enabledArgot ShorthandbooleanfalseLet 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.autoloadArgot Startup LoadbooleantrueLoad 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.modelsArgot Modelsarray[]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.tokenBudgetArgot Dictionary Budgetnumber1000How 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.disableAboveTokensArgot Context Cutoffnumber-1Stop 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.subagentsArgot in SubagentsenumoffHow 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

KeySettingTypeDefaultWhat it does
tools.formatTool Calling ModeenumautoControls 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

KeySettingTypeDefaultWhat it does
autolearn.enabledEnable Auto-LearnbooleanfalseAfter the agent stops, nudge it to capture lessons to memory and create/enhance isolated managed skills.
autolearn.autoContinueAuto-run capture at stopbooleanfalseWhen on, auto-run one capture turn at stop (uses extra tokens). Off = passive reminder on your next turn.

Global

Machine Limits

KeySettingTypeDefaultWhat it does
machine.cpuLimitCoresMachine CPU Limitnumber0Maximum 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.memoryLimitGbMachine Memory Limitnumber0Maximum 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.writeBudgetGbMachine Write Budgetnumber0Cumulative 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.maxProcessesMachine Max Processesnumber0Hard 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

KeySettingTypeDefaultWhat it does
defaultProfileDefault ProfilestringdefaultProfile 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.
onboardingVersionOnboarding Versionnumber0Setup 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

KeySettingTypeDefaultWhat it does
profileSharingShare Credentials Across ProfilesbooleantrueWhen 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

KeySettingTypeDefaultWhat it does
authBrokerUrlAuth Broker URLstring(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.
authBrokerTokenAuth Broker Tokenstring(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.

KeyTypeDefaultNotes
async.maxJobsnumber100
auth.broker.tokenstring(unset)
auth.broker.urlstring(unset)
autolearn.minToolCallsnumber5
bashInterceptor.patternsarray[{"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.reserveTokensnumber16384
commit.changelogMaxDiffCharsnumber120000
commit.mapReduceEnabledbooleantrue
commit.mapReduceMaxConcurrencynumber5
commit.mapReduceMaxFileTokensnumber50000
commit.mapReduceMinFilesnumber4
commit.mapReduceTimeoutMsnumber120000
compaction.autoContinuebooleantrue
compaction.dropUselessbooleantrue
compaction.enabledbooleantrue
compaction.handoffSaveToDiskbooleanfalse
compaction.idleEnabledbooleanfalse
compaction.idleThresholdTokensnumber200000
compaction.idleTimeoutSecondsnumber300
compaction.keepRecentTokensnumber10000
compaction.midTurnEnabledbooleantrue
compaction.remoteEndpointstring(unset)
compaction.reserveTokensnumber(unset)
compaction.supersedeReadsbooleantrue
compaction.thresholdPercentnumber-1Retired: use compaction.threshold instead.
compaction.thresholdTokensnumber-1Retired: use compaction.threshold instead.
cycleOrderarray["smol","slow"]
defaultThinkingLevelenumhighValues: minimal, low, medium, high, xhigh, max, auto. Retired: use defaultEffort instead.
dev.autoqaPush.tokenstring(unset)
disabledExtensionsarray[]
disabledProvidersarray[]
edit.modelVariantsrecord{}
enabledModelsarray[]
eval.pyWorkspacebooleanfalse
extensionsarray[]
gc.archivebooleantrue
gc.blobsbooleantrue
gc.coldArchiveAfterDaysnumber30
gc.retainNewestGlobalnumber20
gc.retainNewestPerCwdnumber10
gc.walbooleantrue
gc.writeGraceMinutesnumber5
harness.profilesrecord{}
hindsight.apiTokenstring(unset)
hindsight.bankIdPrefixstring(unset)
hindsight.bankMissionstring(unset)
hindsight.debugbooleanfalse
hindsight.mentalModelMaxRenderCharsnumber16000
hindsight.mentalModelRefreshIntervalMsnumber300000
hindsight.recallBudgetenummidValues: low, mid, high.
hindsight.recallContextTurnsnumber1
hindsight.recallMaxQueryCharsnumber800
hindsight.recallMaxTokensnumber1024
hindsight.recallTimeoutMsnumber30000
hindsight.recallTypesarray["world","experience"]
hindsight.reflectTimeoutMsnumber120000
hindsight.requestTimeoutMsnumber30000
hindsight.retainContextstringveyyon
hindsight.retainEveryNTurnsnumber3
hindsight.retainMissionstring(unset)
hindsight.retainOverlapTurnsnumber2
hindsight.retainTimeoutMsnumber60000
memories.enabledbooleanfalse
memories.fallbackTokenLimitnumber16000
memories.maxRawMemoriesForGlobalnumber200
memories.maxRolloutAgeDaysnumber30
memories.maxRolloutsPerStartupnumber64
memories.minRolloutIdleHoursnumber12
memories.phase1InputTokenLimitnumber4000
memories.phase2HeartbeatSecondsnumber30
memories.phase2LeaseSecondsnumber180
memories.phase2RetryDelaySecondsnumber180
memories.rolloutPayloadPercentnumber0.7
memories.stage1Concurrencynumber8
memories.stage1LeaseSecondsnumber120
memories.stage1RetryDelaySecondsnumber120
memories.summaryInjectionTokenLimitnumber5000
memories.threadScanLimitnumber300
mnemopi.debugbooleanfalse
mnemopi.injectionTokenLimitnumber5000
mnemopi.recallContextTurnsnumber3
mnemopi.recallLimitnumber8
mnemopi.recallMaxQueryCharsnumber4000
mnemopi.retainEveryNTurnsnumber4
modelProviderOrderarray[]
modelTagsrecord{}
retry.baseDelayMsnumber500
retry.enabledbooleantrue
searxng.basicPasswordstring(unset)
searxng.basicUsernamestring(unset)
searxng.categoriesstring(unset)
searxng.languagestring(unset)
searxng.tokenstring(unset)
settingsMigrationVersionnumber0
setupVersionnumber0Retired: use onboardingVersion instead.
shellMinimizer.exceptarray[]
shellMinimizer.legacyFiltersboolean(unset)
shellMinimizer.maxCaptureBytesnumber4194304
shellMinimizer.onlyarray[]
shellMinimizer.settingsPathstring(unset)
shellPathstring(unset)
skills.enabledbooleantrue
skills.ignoredSkillsarray[]
skills.includeSkillsarray[]
statusLine.leftSegmentsarray[]
statusLine.rightSegmentsarray[]
statusLine.segmentOptionsrecord{}
statusLine.separatorenumpipeValues: powerline, powerline-thin, slash, pipe, block, none, ascii.
statusLine.transparentbooleantrue
stt.languagestringen
subagent.modelByDepthrecord{}Retired: use subagent.agents instead.
thinkingBudgets.highnumber16384
thinkingBudgets.lownumber2048
thinkingBudgets.maxnumber32768
thinkingBudgets.mediumnumber8192
thinkingBudgets.minimalnumber1024
thinkingBudgets.xhighnumber32768
ttsr.experimentalRulesarray[]
tui.maxInlineImageColumnsnumber100
tui.maxInlineImageRowsnumber20
tui.maxInlineImagesnumber8

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 integration
  • src/config/model-resolver.ts: parses model patterns and selects initial/smol/slow models
  • src/config/settings-schema.ts: model-related settings (modelRoles, provider transport preferences)
  • src/session/auth-storage.ts: re-exports AuthStorage from @veyyon/ai (packages/ai/src/auth-storage.ts); API key + OAuth resolution order
  • packages/catalog/src/models.ts and packages/catalog/src/types.ts: built-in providers/models (getBundledModels / getBundledProviders) and Model/compat types

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.json exists at the same location, it is migrated to models.yml.
  • Explicit .json / .jsonc config paths are still supported when passed programmatically to ModelRegistry.

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:

  • overrides maps an exact concrete selector (provider/modelId) to an official upstream canonical id
  • exclude opts 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-completions
  • openai-responses
  • openai-codex-responses
  • azure-openai-responses
  • anthropic-messages
  • google-generative-ai
  • google-gemini-cli
  • google-vertex

Allowed auth/discovery values

  • auth: apiKey (default), none, or oauth; for models.yml custom models, oauth is accepted by schema but does not waive the apiKey requirement
  • type under discovery: ollama, llama.cpp, lm-studio, openai-models-list, proxy, or litellm
  • transport: pi-native only. When set, every model under that provider is sent to an veyyon auth-gateway compatible baseUrl via POST /v1/pi/stream; apiKey is the gateway bearer.

Validation rules (current)

Full custom provider (models is non-empty)

Required:

  • baseUrl
  • apiKey unless auth: none
  • api at provider level or each model

Override-only provider (models missing or empty)

Must define at least one of:

  • baseUrl
  • apiKey
  • auth: none
  • headers
  • compat
  • disableStrictTools
  • modelOverrides
  • discovery

Discovery

  • discovery requires provider-level api, except discovery.type: proxy (per-model wire auto-detected).

Model value checks

  • id required
  • contextWindow and maxTokens must be positive if provided

Secret values

Provider apiKey values and provider/model headers values are resolved in this order:

  1. 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.
  2. ${NAME} or $NAME reads the environment variable NAME.
  3. A bare value shaped like an environment variable name (DEEPSEEK_API_KEY, upper case, digits and underscores) reads that variable too.
  4. literal:<text> is <text>, verbatim, with no lookup.
  5. Any other value is itself, which is how a key such as sk-... or sk_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):

  1. Load built-in providers/models from @veyyon/catalog (getBundledProviders / getBundledModels).
  2. Load models.yml / models.yaml custom config.
  3. Apply provider overrides (baseUrl, headers, disableStrictTools) to built-in models.
  4. Apply modelOverrides (per provider + model id).
  5. Merge custom models:
    • same provider + id replaces existing
    • otherwise append
  6. 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-6
  • claude-haiku-4-5
  • gpt-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:

  1. exact user override from equivalence.overrides
  2. bundled official-id matches from built-in model metadata
  3. conservative heuristic normalization for gateway/provider variants
  4. fallback to the concrete model’s own id

Current heuristics are intentionally narrow:

  • embedded upstream prefixes can be stripped when present, for example anthropic/... or openai/...
  • 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:

  1. availability and auth
  2. config.yml modelProviderOrder
  3. existing registry/provider order if modelProviderOrder is 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 headers are baseline.
  • Model headers override provider header keys.
  • modelOverrides can override model metadata (name, reasoning, thinking, input, supportsTools, cost, premiumMultiplier, contextWindow, maxTokens, omitMaxOutputTokens, headers, compat, contextPromotionTarget).
  • compat is 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, or OLLAMA_HOST, or http://127.0.0.1:11434
  • context window: OLLAMA_CONTEXT_LENGTH if set, otherwise Ollama /api/show metadata, otherwise 128000
  • auth mode: keyless (auth: none behavior)

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_URL or http://127.0.0.1:8080
  • auth mode: keyless (auth: none behavior)

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_URL or http://127.0.0.1:1234/v1
  • auth mode: keyless (auth: none behavior)

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.yml config, otherwise LITELLM_BASE_URL, otherwise http://localhost:4000/v1
  • auth mode: LITELLM_API_KEY or 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 api if 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:

  1. Runtime override (CLI --api-key)
  2. Stored API key credential in agent.db
  3. Stored OAuth credential in agent.db (with refresh)
  4. Environment variable mapping (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.)
  5. ModelRegistry fallback resolver (provider apiKey from models.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: none are treated as available without credentials.
  • getApiKey* returns kNoAuth for 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 :thinkingLevel suffix (off|minimal|low|medium|high|xhigh|max)

--provider is legacy; --model is preferred.

Resolution precedence for exact selectors:

  1. exact provider/modelId bypasses coalescing
  2. exact canonical id resolves through the canonical index
  3. exact bare concrete id still works
  4. 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:

  1. an explicit --model (or the legacy --provider pair). A pattern that matches nothing is fatal, except for a bare id with no provider and no : suffix, which is carried as options.modelPattern and resolved again after extensions load, since an extension may register the provider it names.
  2. the scoped set from --models, when this is not a --continue or --resume. Inside that set the remembered modelRoles.default wins if it is there; if it is configured but unavailable, fallbackForUnavailableDefault substitutes and prints the reason; otherwise the first scoped model is used.
  3. otherwise nothing is pinned here, and the session resolves modelRoles.default through resolveModelRoleValue against 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):

RoleSelectable in UIPurpose
defaultNo (hidden)Storage key for the interactive model (/model persist / “set as default”)
smolYesFast / cheap (--smol)
slowYesThinking (--slow)
visionYesMultimodal
planYesPlan mode (--plan)
designerYesDesign-oriented work
commitYesCommit / changelog
tinyYesLightweight background (titles, classifiers); else @smol
advisorYesAdvisor 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 (openai or anthropic request format)
  • providers.openaiWebsockets (auto|off|on websocket preference for OpenAI Codex transport)

modelRoles may store either:

  • provider/modelId to pin a concrete provider variant
  • a canonical id such as gpt-5.3-codex to allow provider coalescing

For enabledModels and CLI --models:

  • exact canonical ids expand to all concrete variants in that canonical group
  • explicit provider/modelId entries 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:

  • /model includes a canonical view alongside provider tabs
  • veyyon models prints provider-grouped tables of every concrete model (ls, find, and refresh actions)

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:

  1. If contextPromotion.enabled is true, resolve a promotion target (see below).
  2. If a target is found, switch to it and retry the request: no compaction needed.
  3. If no target is available, fall through to auto-compaction on the current model.

Target selection

Selection is explicit and model-driven:

  1. 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_change in 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: emit store: false on requests. Default: auto (off for non-standard endpoints).
  • supportsDeveloperRole: use the developer system role for reasoning models instead of system. 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: send stream_options: { include_usage: true } to receive token usage on streaming responses. Default: true.
  • maxTokensField: "max_completion_tokens" or "max_tokens". Default: auto.
  • supportsToolChoice: emit the tool_choice parameter when the caller forces a specific tool. Default: true. Set false for endpoints that 400 on tool_choice (e.g. DeepSeek when reasoning is on).
  • supportsForcedToolChoice: accept a forced tool_choice that requires a specific tool. Default: true. When false, a forced selector is downgraded to auto so the tool stays available for endpoints that reject forced tool calls (e.g. some thinking-required OpenAI-compatible models).
  • disableReasoningOnForcedToolChoice: drop reasoning_effort / OpenRouter reasoning whenever tool_choice forces a call. Default: auto (Kimi/Anthropic-fronted endpoints).
  • disableReasoningOnToolChoice: drop reasoning fields whenever any tool_choice is 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 from max_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 (OpenRouter anthropic/* models).
  • supportsLongPromptCacheRetention: host honors prompt_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: accept reasoning_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 maps minimal -> "none"). Every key must name a level; a key that does not, such as a misspelled hihg, 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-level enable_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; set false for 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 a name field (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-tool strict field 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):

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:

  1. The bundled model catalog (every built-in provider and its known models).
  2. Custom provider and model entries from ~/.veyyon/profiles/default/agent/models.yml.
  3. Runtime-discovered models for providers that support discovery (local engines and discovery-enabled gateways).
  4. 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:

  1. its provider ID is not in the effective disabledProviders list; and
  2. 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):

  1. Runtime override: a key supplied for the current process, e.g. CLI --api-key. Never persisted.
  2. models.yml config key: an apiKey pinned on a custom provider, registered as a config-sourced bearer. This deliberately beats stored OAuth, so a key supplied for a custom baseUrl/gateway is honored instead of forwarding an upstream OAuth token the proxy would reject.
  3. Stored API key: an API-key credential saved in the auth store.
  4. 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.
  5. Provider environment variable: including values loaded from .env files (see the env-var table).
  6. models.yml fallback 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 IDEnvironment variable(s)
anthropicANTHROPIC_OAUTH_TOKEN, then ANTHROPIC_API_KEY (Foundry mode prefers ANTHROPIC_FOUNDRY_API_KEY when CLAUDE_CODE_USE_FOUNDRY=true)
openaiOPENAI_API_KEY
openai-codexOPENAI_CODEX_OAUTH_TOKEN
googleGEMINI_API_KEY
google-vertexGOOGLE_CLOUD_API_KEY, or Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS + GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION)
groqGROQ_API_KEY
openrouterOPENROUTER_API_KEY
mistralMISTRAL_API_KEY
xaiXAI_API_KEY
xai-oauthXAI_OAUTH_TOKEN, then XAI_API_KEY
github-copilotCOPILOT_GITHUB_TOKEN
cursorCURSOR_ACCESS_TOKEN
azureAZURE_OPENAI_API_KEY
amazon-bedrockAWS_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 IDEnvironment variable(s)
cerebrasCEREBRAS_API_KEY
command-codeCMD_API_KEY, then COMMAND_CODE_API_KEY
deepseekDEEPSEEK_API_KEY
fireworksFIREWORKS_API_KEY
togetherTOGETHER_API_KEY
nvidiaNVIDIA_API_KEY
huggingfaceHUGGINGFACE_HUB_TOKEN, then HF_TOKEN
moonshotMOONSHOT_API_KEY, then KIMI_API_KEY
nanogptNANO_GPT_API_KEY
novitaNOVITA_API_KEY
veniceVENICE_API_KEY
vercel-ai-gatewayAI_GATEWAY_API_KEY (also VERCEL_AI_GATEWAY_API_KEY for catalog discovery)
cloudflare-ai-gatewayCLOUDFLARE_AI_GATEWAY_API_KEY
litellmLITELLM_API_KEY; optional LITELLM_BASE_URL for the proxy endpoint
kiloKILO_API_KEY
zaiZAI_API_KEY
zenmuxZENMUX_API_KEY
zhipu-coding-planZHIPU_API_KEY
umansUMANS_AI_CODING_PLAN_API_KEY
qianfanQIANFAN_API_KEY
qwen-portalQWEN_OAUTH_TOKEN, then QWEN_PORTAL_API_KEY
syntheticSYNTHETIC_API_KEY
minimaxMINIMAX_API_KEY
minimax-codeMINIMAX_CODE_API_KEY
minimax-code-cnMINIMAX_CODE_CN_API_KEY
basetenBASETEN_API_KEY
coreweaveCOREWEAVE_API_KEY, then WANDB_API_KEY
devinDEVIN_API_KEY
nous-researchNOUS_API_KEY (headless fallback; prefer /login nous-research)
gitlab-duo-agentGITLAB_TOKEN
sakanaSAKANA_API_KEY, then FUGU_API_KEY
xiaomi-token-plan-amsXIAOMI_TOKEN_PLAN_AMS_API_KEY
xiaomi-token-plan-cnXIAOMI_TOKEN_PLAN_CN_API_KEY
xiaomi-token-plan-sgpXIAOMI_TOKEN_PLAN_SGP_API_KEY
alibaba-coding-planALIBABA_CODING_PLAN_API_KEY
aimlapiAIMLAPI_API_KEY
gitlab-duoGITLAB_TOKEN
opencode-zen, opencode-goOPENCODE_API_KEY
firepassFIREPASS_API_KEY
wafer-serverlessWAFER_SERVERLESS_API_KEY
xiaomiXIAOMI_API_KEY
ollama-cloudOLLAMA_CLOUD_API_KEY
ollamaOLLAMA_API_KEY (optional; local discovery is keyless by default)
vllmVLLM_API_KEY (optional; local discovery is keyless by default)
lm-studioLM_STUDIO_API_KEY (optional; keyless by default)
llama.cppLLAMA_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:

  1. The process environment inherited by veyyon (already-set variables always win).
  2. <cwd>/.env
  3. <agentDir>/.env, by default ~/.veyyon/profiles/default/agent/.env
  4. <configRoot>/.env, by default ~/.veyyon/profiles/default/.env
  5. ~/.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 IDBase URL (env override → default)Notes
ollamaOLLAMA_BASE_URL, then OLLAMA_HOST (normalized), else http://127.0.0.1:11434Keyless by default.
llama.cppLLAMA_CPP_BASE_URL, else http://127.0.0.1:8080Keyless unless a key is stored for llama.cpp.
lm-studioLM_STUDIO_BASE_URL, else http://127.0.0.1:1234/v1Keyless 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 disabledProviders list.

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.yml providers;
  • 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:

  • ollama is disabled everywhere.
  • anthropic and openai are additionally disabled under ~/projects/sensitive.
  • openrouter is additionally disabled under ~/work/client-a and ~/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 custom models.yml ID, …). 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 typeExamplesEffect
Model provider IDanthropic, openai, google, groq, openrouter, ollama, my-gatewayRemoves that provider’s models from availability.
Discovery provider IDnative, claude, codex, gemini, agents, githubStops 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.json when 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 tooling
  • mcpServers: map of server name to server config
  • enabledServers: user-level list that overrides a discovered server’s enabled: false flag (for example when the source config is owned by another tool such as opencode.json); disabledServers still wins
  • disabledServers: 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.json under 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 when false
  • timeout?: number: MCP request timeout in milliseconds; 0 disables client-side MCP timeouts
  • auth?: { ... }: auth metadata used by Veyyon for OAuth/API-key flows
  • oauth?: { ... }: 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 name
  • inheritEnv?: boolean — forward the whole ambient environment, credentials included
  • cwd?: 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:

  1. 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.
  2. ${NAME} or $NAME reads the environment variable NAME.
  3. A bare value shaped like an environment variable name (GITHUB_TOKEN, upper case, digits and underscores) reads that variable too.
  4. literal:<text> is <text>, verbatim, with no lookup.
  5. 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" → send PROJECT_KEY as 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 provider apiKey, 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 reload to rediscover and reconnect servers in the current session
  • /mcp list to 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 notifications to inspect non-tool MCP capabilities

Validation rules Veyyon enforces

From validateServerConfig() in packages/coding-agent/src/mcp/config.ts:

  • stdio requires command
  • http and sse require url
  • a server cannot set both command and url
  • unknown type values are rejected

Practical implications:

  • Omitting type means stdio
  • If you paste a remote server config and forget "type": "http", Veyyon will treat it as stdio and complain that command is missing
  • sse remains valid for compatibility, but new hosted servers should usually be configured as http

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.json when you want a Veyyon-specific override
  • keep server names unique across tools when possible
  • use disabledServers in 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:

  1. Tool declaration: every tool may declare an approval tier:
    • 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.
  2. User policy: tools.approval.<toolName>: allow | deny | prompt overrides the mode for that tool unless a tool’s safety override forces a prompt. A deny always wins, on every rung. An allow wins over a critical safety prompt on yolo, which is the escape hatch from the floor; below yolo a safety override still prompts over it, because that is what makes the shipped auto rung 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:

ModeAuto-approvesPrompts for
planreadwrite only inside an active plan-mode session; write and exec are otherwise denied
asknothingread, write, exec
ask-commandread, writeexec
auto (default)read, write, execa per-tool policy, the working-directory boundary, credential use, and a tool’s own critical calls
yoloread, write, execnone

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-askask, write and auto-editask-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 critical safety 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. Set tools.approval.<tool>: allow if 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:

  1. Compute the tool’s approval decision from tool.approval(args); omitted means exec.
  2. Normalize tools.approval.<tool> if the key is present. allow, deny and prompt are 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.
  3. In yolo mode, the user policy is used when present. Otherwise a critical decision prompts and everything else is allowed: plain override reasons do not force a prompt in yolo, but critical ones do.
  4. In non-yolo modes, if the tool sets override: true, deny is blocked and all other cases prompt, even if user policy is allow.
  5. Otherwise, a valid user policy wins.
  6. 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 tool for unannotated mcp__... tools
  • Reason: <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 fileWhat it grants
.veyyon/plugins/installed_plugins.jsonextensions, hooks, custom tools, slash commands and MCP servers, from the directories it names
an extension or hook file inside the projectmodule 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.
ReasonMeaning
has not been trustedno decision exists for this project
marked untrustedyou denied this project
changed since it was trustedthe file’s bytes differ from the approved ones
not part of the approved setthe 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 for SymbolKey)

Color values accept:

  • hex string ("#RRGGBB")
  • 256-color index (0..255)
  • variable reference string (resolved through vars)
  • empty string ("") meaning terminal default (\x1b[39m fg, \x1b[49m bg)

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.pageBg
  • export.cardBg
  • export.infoBg

If omitted, export code derives defaults from resolved theme colors.

symbols section (optional)

  • symbols.preset sets a theme-level default symbol set.
  • symbols.overrides can override individual SymbolKey values.
  • symbols.spinnerFrames overrides the loading spinner frames. Accepts either a flat string[] (applied to the status and activity spinners) 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. status drives the ~12.5fps spinner used by loaders and tool-execution indicators; activity drives the ~30fps spinner used by markdown progress bars and similar high-frequency UI; thinking drives 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:

  1. settings symbolPreset override (if set)
  2. theme JSON symbols.preset
  3. 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):

  1. built-in embedded themes (dark.json, light.json, and all defaults/*.json compiled into defaultThemes)
  2. 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:

  1. read JSON
  2. parse JSON
  3. validate against themeJsonSchema
  4. resolve vars references recursively
  5. 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 => truecolor
  • WT_SESSION => truecolor
  • TERM in dumb, linux, or empty => 256color
  • otherwise => truecolor

Conversion behavior:

  • hex -> Bun.color(..., "ansi-16m" | "ansi-256")
  • numeric -> 38;5 / 48;5 ANSI
  • "" -> default fg/bg reset

Runtime switching behavior

Initial theme (initTheme)

main.ts initializes theme with settings:

  • symbolPreset
  • colorBlindMode
  • theme.dark
  • theme.light

Auto theme slot selection uses terminal appearance in this order:

  1. terminal-reported OSC 11 background luminance, unless the macOS/Zellij fallback path is active
  2. COLORFGBG background index (< 8 => dark, >= 8 => light)
  3. macOS appearance fallback only for the known-broken macOS/Zellij OSC 11 path
  4. 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 theme singleton
  • optionally starts watcher
  • triggers onThemeChange callback

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>.json only 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:

  • toolDiffAdded is 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>/agent under a named profile)
  • effective default file: ~/.veyyon/profiles/default/agent/config.yml

Persisted keys:

  • theme.dark
  • theme.light
  • symbolPreset
  • colorBlindMode

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)

  1. Create file in custom themes dir, e.g. ~/.veyyon/profiles/default/agent/themes/my-theme.json.
  2. Include name, optional vars, and all required colors tokens.
  3. Optionally include symbols and export.
  4. Select the theme in Settings (Appearance -> Dark Theme or Appearance -> 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:

  1. Start interactive mode (watcher enabled from startup).
  2. Open settings and preview theme values (live previewTheme).
  3. For custom theme files, edit the JSON while running and confirm auto-reload on save.
  4. 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
  5. Validate both symbol presets if your theme depends on glyph width/appearance.

Real constraints and caveats

  • All colors tokens are required for custom themes.
  • export and symbols are optional.
  • $schema in theme JSON is informational; runtime validation is enforced by an arktype schema in code.
  • setTheme failure falls back to dark; previewTheme failure 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

  • --hook is treated as an alias for --extension (CLI paths are merged into additionalExtensionPaths)
  • 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 their pi.on(...) handlers bind to the runtime event bus
  • tools are wrapped by ExtensionToolWrapper, not HookToolWrapper
  • 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 contracts
  • src/extensibility/hooks/loader.ts: module loading and hook discovery bridge
  • src/extensibility/hooks/runner.ts: event dispatch, command lookup, error signaling
  • src/extensibility/hooks/tool-wrapper.ts: pre/post tool interception wrapper
  • src/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 via pi.pi

Discovery and loading

Default sessions load JS/TS hook factories discovered by hookCapability through the extension runner. discoverExtensionPaths(configuredPaths, cwd) does:

  1. Load native extension modules from the capability registry
  2. Load importable .ts/.js hook factories from the hook capability registry
  3. Append plugin extension entry points
  4. Append explicitly configured paths

The legacy discoverAndLoadHooks(configuredPaths, cwd) helper still exists and does:

  1. Load discovered hooks from capability registry (loadCapability("hooks"))
  2. Append explicitly configured paths (deduped by absolute path)
  3. 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_start
  • session_before_switch → can return { cancel?: boolean }
  • session_switch
  • session_before_branch → can return { cancel?: boolean; skipConversationRestore?: boolean }
  • session_branch
  • session_before_compact → can return { cancel?: boolean; compaction?: CompactionResult }
  • session_compacting → can return { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
  • session_compact
  • session_before_tree → can return { cancel?: boolean; summary?: { summary: string; details?: unknown } }
  • session_tree
  • session_shutdown

Agent/context events

  • context → can return { messages?: Message[] }
  • before_agent_start → can return { message?: { customType; content; display; details; attribution? } }
  • agent_start
  • agent_end
  • turn_start
  • turn_end
  • auto_compaction_start
  • auto_compaction_end
  • auto_retry_start
  • auto_retry_end
  • ttsr_triggered
  • todo_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 reason becomes 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, input
  • content
  • details
  • isError: false

If handler returns overrides:

  • content can replace result content
  • details can 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 (messages replacement chain)
  • tool output content/details on successful tool calls (tool_result path)
  • pre-agent injected message via before_agent_start
  • cancellation/custom compaction/tree behavior via session_before_* and session_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 isError is typed but not applied by HookToolWrapper)

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:

  1. hooks array order
  2. handler registration order per hook/event

Conflict behavior by event type:

  • tool_call: last returned result wins unless a handler blocks; first block short-circuits
  • tool_result: last returned override wins (no short-circuit)
  • context: chained; each handler receives prior handler’s message output
  • before_agent_start: first returned message is kept; later messages ignored
  • session_before_*: latest returned result is tracked; cancel: true short-circuits immediately
  • session_compacting: latest returned result wins

Command/renderer conflicts:

  • getCommand(name) returns first match across hooks (first loaded wins)
  • getMessageRenderer(customType) returns first match
  • getRegisteredCommands() returns all commands (no dedupe)

UI interactions (HookContext.ui)

HookUIContext includes:

  • select, confirm, input, editor
  • notify
  • setStatus
  • custom
  • setEditorText, getEditorText
  • theme getter

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/editor return undefined
  • confirm returns false
  • notify, setStatus, setEditorText are no-ops
  • getEditorText returns ""

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
  • execCommand re-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 read tool against skill://...
  • 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:

  • name
  • description
  • filePath (the SKILL.md path)
  • 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?: string
  • description?: string
  • globs?: string[]
  • alwaysApply?: boolean
  • hide?: boolean
  • disableModelInvocation?: boolean (Agent Skills equivalent of hide; normalized from kebab-case disable-model-invocation)
  • additional keys are preserved as unknown metadata

Current runtime behavior:

  • name defaults to the skill directory name
  • description is required for both providers that load skills ambiently:
    • native .veyyon provider (requireDescription: true), the profile’s skills/ dir
    • veyyon-plugins extension-package skills (requireDescription: true)
  • 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/skills dir, user level only, via src/discovery/builtin.ts. Project-local .veyyon/skills is deliberately not scanned.
  • veyyon-plugins (priority 90): skills/ bundled with plugins installed into the active profile
  • veyyon-managed (priority 5): auto-learn skills under .../agent/managed-skills, discovered unconditionally (only writing/nudging is gated by autolearn.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:

  • disabledExtensions entries with skill:<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.ts additionally:
    • 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 over scanSkillsFromDir

Runtime usage behavior

System prompt exposure

System prompt construction (src/system-prompt.ts) uses discovered skills as follows:

  • if read tool is available:
    • include discovered skills list in prompt, excluding skills with hide: true
  • 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 steer queue 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 the followUp queue while streaming, or as a normal idle prompt when the agent is not streaming
  • appends metadata ([Skill directory: <baseDir>], optional User: <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’s SKILL.md
  • skill://<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 found error

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 name and description frontmatter
  • 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 TreeSelectorComponent with 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, /branch command 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 (navigateTree leaf 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.tree action
  • double-escape on empty editor when doubleEscapeAction = "tree" (default)
  • /branch when doubleEscapeAction = "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 down
  • Enter: select node
  • Esc: clear search if active; otherwise close selector
  • Ctrl+C: close selector
  • Type: append to search query
  • Backspace: delete search character
  • Shift+L: edit/clear label on selected entry
  • Ctrl+O: cycle filter forward
  • Shift+Ctrl+O: cycle filter backward
  • Alt+D/T/U/L/A: jump directly to specific filter mode

Filters and search semantics

Filter modes (TreeList):

  1. default
  2. no-tools
  3. user-only
  4. labeled-only
  5. all

default

Shows conversational nodes plus any entry types not explicitly suppressed. It hides these setting/bookkeeping entry types:

  • label
  • custom
  • model_change
  • thinking_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 (stopReason not stop/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 summary
  • Summarize
  • Summarize 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 Esc to abortBranchSummary()
  • 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 exists
    • branch(newLeafId) for non-root move without summary
    • resetLeaf() 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 label entries
  • tree nodes display resolved label state, not raw label-entry history

/tree vs adjacent operations

OperationScopeResult
/treeCurrent session fileMoves leaf to selected point (same file)
/branchUsually current session file -> new session fileBy default branches from selected user message into a new session file; if doubleEscapeAction = "tree", /branch opens tree navigation UI instead
/forkWhole current sessionDuplicates session into a new persisted session file
/resumeSession listSwitches 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

  1. /tree
  2. search/select earlier user message
  3. choose No summary (or summarize if needed)
  4. edit prefilled text in editor
  5. submit

Effect: new branch grows from selected point within same session file.

Leave current branch with context breadcrumb

  1. enable branchSummary.enabled
  2. /tree and select target node
  3. choose Summarize (or custom prompt)

Effect: a branch_summary entry is appended at the target position before continuing.

Investigate hidden bookkeeping entries

  1. /tree
  2. press Alt+A (all)
  3. search for model, thinking, custom, or labels

Effect: inspect full internal timeline, not just conversational nodes.

Bookmark pivot points for later jumps

  1. /tree
  2. move to entry
  3. Shift+L and set label
  4. 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.ts
  • src/modes/rpc/rpc-types.ts
  • src/session/agent-session.ts
  • packages/agent/src/agent.ts
  • packages/agent/src/agent-loop.ts

Startup

veyyon --mode rpc [regular CLI options]

Behavior notes:

  • @file CLI 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, and memories.enabled, plus async.enabled, async.maxJobs, bash.autoBackground.enabled, and bash.autoBackground.thresholdMs. The default is only applied when the path is unset: any explicit configuration (caller overrides, --config overlays, or the profile config.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)

  1. Ready frame ({ type: "ready" })
  2. RpcResponse ({ type: "response", ... })
  3. AgentSessionEvent objects (agent_start, message_update, etc.)
  4. RpcExtensionUIRequest ({ type: "extension_ui_request", ... })
  5. Host tool requests/cancellations (host_tool_call, host_tool_cancel)
  6. Host URI requests/cancellations (host_uri_request, host_uri_cancel)
  7. Extension errors ({ type: "extension_error", extensionPath, event, error })
  8. Available-commands updates ({ type: "available_commands_update", commands }), emitted at startup and whenever command metadata changes
  9. Prompt lifecycle hints ({ type: "prompt_result", id?, agentInvoked }) for scheduled prompts that later resolve without invoking the agent
  10. Subagent frames (subagent_lifecycle, subagent_progress, subagent_event), gated by set_subagent_subscription
  11. Builtin slash-command side channels (command_output, session_info_update, config_update)

Inbound frame categories (stdin)

  1. RpcCommand
  2. RpcExtensionUIResponse ({ type: "extension_ui_response", ... })
  3. Host tool updates/results (host_tool_update, host_tool_result)
  4. Host URI results (host_uri_result)

Request/Response Correlation

All commands accept optional id?: string.

  • If provided, normal command responses echo the same id.
  • RpcClient relies on this for pending-request resolution.

Important edge behavior from runtime:

  • Unknown command responses are emitted with id: undefined (even if the request had an id).
  • Parse/handler exceptions in the input loop emit command: "parse" with id: undefined.
  • prompt and abort_and_prompt return immediate success, then may emit a later error response with the same id if async prompt scheduling fails.
  • prompt success responses may include data.agentInvoked. false means the prompt completed locally without an agent turn; true means the prompt produced agent lifecycle events; omitted means the host must rely on session events for completion.
  • abort_and_prompt does not currently emit data.agentInvoked or prompt_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_end
  • turn_start, turn_end
  • message_start, message_update, message_end
  • tool_execution_start, tool_execution_update, tool_execution_end
  • auto_compaction_start, auto_compaction_end
  • auto_retry_start, auto_retry_end
  • ttsr_triggered
  • todo_reminder
  • todo_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: false on the response or via a later prompt_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, cancel
  • notify, setStatus, setWidget, setTitle, set_editor_text
  • open_url (emitted by RPC login flows)

Runtime note:

  • Automatic session title generation is disabled in RPC mode, and setTitle UI requests are also suppressed by default because most hosts do not have a meaningful terminal-title surface. Set VEYYON_RPC_EMIT_TITLE=1 to 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 edit tool does not target host URIs. Hosts that want to mutate virtual files expose write and let the model use the write tool with replacement content.
  • Schemes are global to the process; set_host_uri_schemes replaces 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 parse error response and continue reading subsequent lines.
  • Empty set_session_name is rejected (Session name cannot be empty).
  • Extension UI responses with unknown id are 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 AgentEvent types to listeners
  • Supports host-owned custom tools via setCustomTools() and automatic handling of host_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:

  • createAgentSession
  • SessionManager
  • Settings
  • AuthStorage
  • ModelRegistry
  • discoverAuthStorage
  • 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 via getAgentDir() (default ~/.veyyon/profiles/default/agent; named profile ~/.veyyon/profiles/<name>/agent)
  • globalConfigRoot: cross-profile vault/key root via getGlobalConfigRootDir() (normally ~/.veyyon)
  • authStorage: discoverAuthStorage(agentDir)
  • modelRegistry: new ModelRegistry(authStorage) + background refreshInBackground() when the registry is not provided
  • settings: 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: new EventBus() 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)
    • model or modelPattern (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.
  • sessionFile is 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:

  1. restore model from existing session (if restorable + key available)
  2. settings default model role (default)
  3. first available model with valid auth

If restore fails, modelFallbackMessage explains fallback.

Auth priority

AuthStorage.getApiKey(...) resolves in this order:

  1. runtime override (setRuntimeApiKey, used by CLI --api-key)
  2. config-sourced API key override (models.yml provider apiKey)
  3. stored OAuth credential, including refresh when needed
  4. stored login-sourced API-key credential
  5. provider environment variables
  6. stored non-login API-key credential (may be a stale broker-migrated copy)
  7. 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_end
  • auto_retry_start / auto_retry_end
  • retry_fallback_applied / retry_fallback_succeeded
  • ttsr_triggered
  • todo_reminder / todo_auto_clear
  • irc_message

Prompt lifecycle

session.prompt(text, options?) is the primary entry point.

Behavior:

  1. optional command/template expansion (/ commands, custom commands, file slash commands, prompt templates)
  2. if currently streaming:
    • streamingBehavior: "steer" | "followUp" chooses how prompt() queues
    • extension sendUserMessage(content) defaults to steer when deliverAs is omitted
    • queued messages are preserved instead of throwing work away
  3. 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(...) and BUILTIN_TOOLS.
  • toolNames acts as an allowlist for built-ins.
  • customTools and 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: inline ExtensionFactory[]
  • additionalExtensionPaths: load extra extension files
  • disableExtensionDiscovery: disable automatic extension scanning
  • preloadedExtensions: 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 context
  • requireYieldTool: forces yield tool inclusion
  • taskDepth: recursion-depth context for nested task sessions
  • parentTaskPrefix: 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 real fetch(...) then reuses the warm connection, saving 100–300 ms on transcontinental hops (e.g. residential IP → api.anthropic.com). Implementation lives in preconnectModelHost() in packages/coding-agent/src/sdk.ts. If Bun’s preconnect is 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 !== false on the session options, and
    • options.hasUI === true (interactive TUI), and
    • the lsp.lazy setting is disabled (it defaults to true).

    With lsp.lazy enabled, the default, no language servers are launched at startup at all; each server cold-starts on first use, i.e. when the agent invokes the lsp tool or an edit/write touches a file whose extension matches the server’s fileTypes. 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 big initialize responses concurrently with the LLM stream consumer and jitters perceived latency. Tools that actually need an LSP server still spin one up on demand through getOrCreateClient(), only the startup warmup is skipped. The returned lspServers field in CreateAgentSessionResult is 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 /status can list them; it is undefined only when enableLsp === false or hasUI === 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.

CodeMeaning
0Success.
1A 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.
2A 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.
130Veyyon 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.
NWhen Veyyon runs a child process (for example a shell tool command), the child’s own exit code passes through unchanged.
128 + signalOn 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 to 1, 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:

PathContents
config.ymlGlobal 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.mdGlobal 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-idPersistent 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:

PathContents
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.jsonUsage 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:

PathContents
config.ymlThis profile’s settings (config.yaml also accepted). See Configuration.
agent.dbSettings + auth storage (SQLite).
sessions/Saved session transcripts, one per thread.
blobs/Content-addressed attachment/blob store.
history.db, models.dbComposer history, model cache.
skills/, commands/, prompts/, tools/, themes/, modules/Skills, slash commands, prompt templates, custom tools, themes, Python modules.
mcp.json, ssh.jsonMCP server and SSH target config.
keybindings.ymlThis profile’s keybindings (keybindings.yaml accepted; legacy keybindings.json migrates on load).
AGENTS.mdProfile-specific context appended to the assembled prompt.
RULES.mdSticky profile rules reattached near each turn.
TITLE_SYSTEM.mdOptional 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:

  1. --profile <name> on the command line.
  2. VEYYON_PROFILE. An explicitly empty VEYYON_PROFILE= forces the default profile, bypassing step 3.
  3. defaultProfile in the global ~/.veyyon/config.yml: set it with veyyon profile default <name>.
  4. The default profile.

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):

PathPurpose
AGENTS.md / CLAUDE.mdProject 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

AreaResponsibilityHandbook
SessionsJSONL trees, resume, fork, compactSessions
EditHashline patches (default)Edit engine
ApprovalsApproval-mode gating on tool tiersApprovals
ConfigLayered config.yml, profilesConfig
MCPExternal tool serversMCP
ProvidersModel registry + authProviders
Memoryoff / local / mnemopi / hindsightMemory

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) for bash, edit, write, and related tools.
  • Apply per-tool overrides (tools.approvalallow / 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 shipped auto included.
  • 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, yolo included: that is a floor rather than an ordinary prompt, and only an explicit tools.approval.bash: allow lifts it, while deny remains a hard block. The merely dangerous ones (curl | sh, reboot, nc -e) prompt on every rung below yolo.
  • 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

ConceptMeaning
Approval modeWhich tool tiers run without asking (plan, ask, ask-command, auto (default), yolo; legacy always-askask, write and auto-editask-command).
Per-tool policytools.approval overrides the mode for a named tool.
Flagged bash patternsHard-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 modeRestricts 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 / parentId linkage
  • 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 AgentSession runs 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 --config overlays 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.ts
  • packages/coding-agent/src/config/config-file.ts (re-exported from config.ts)
  • packages/coding-agent/src/config/settings.ts
  • packages/coding-agent/src/config/settings-schema.ts
  • packages/coding-agent/src/discovery/builtin.ts
  • packages/coding-agent/src/discovery/helpers.ts

Key integration points:

  • packages/coding-agent/src/capability/index.ts
  • packages/coding-agent/src/discovery/index.ts
  • packages/coding-agent/src/extensibility/skills.ts
  • packages/coding-agent/src/extensibility/hooks/loader.ts
  • packages/coding-agent/src/extensibility/custom-tools/loader.ts
  • packages/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:

  1. .veyyon (native)
  2. .claude
  3. .codex
  4. .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 (default true)
  • project (default true)
  • cwd (default getProjectDir())
  • existingOnly (default false)

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():
    • ok
    • not-found
    • error (ConfigError with schema/parse context)

Legacy migration still supported:

  • If target path is .yml/.yaml, a sibling .json is auto-migrated once (migrateJsonToYml).

4) Settings resolution model (src/config/settings.ts)

The runtime settings model is layered:

  1. Profile settings: ~/.veyyon/profiles/<name>/agent/config.yml
  2. CLI config overlays: veyyon --config <path> / repeated --config files, loaded as config.yml-style YAML for this process only
  3. Runtime overrides: in-memory, non-persistent
  4. 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:

  1. Migrate from ~/.veyyon/profiles/default/agent/settings.json (renamed to .bak on success)
  2. Merge with legacy DB settings from agent.db
  3. Write merged result to config.yml

Field-level migrations in #migrateRawSettings:

  • queueMode -> steeringMode
  • ask.timeout milliseconds -> 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 why packages/coding-agent/test/settings-migration-idempotence.test.ts pins the property.
  • Legacy flat theme: "..." -> theme.dark/theme.light structure

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/skills directories are deliberately not scanned, so no repository can inject skills into a session by ambient autodiscovery.
  • AGENTS.md has 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.md at the nearest non-empty .veyyon/ claims its level, bare AGENTS.md next, bare CLAUDE.md last). RULES.md is the active profile’s file only; a repository’s .veyyon/RULES.md is not read. Persistent system-prompt changes use PROMPT_SECTIONS/ under the active profile’s agent dir. See docs/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 .ts and .js entries load; anything else is reported as skipped
  • Tools: tools/*.{json,md,ts,js,sh,bash,py} and tools/<name>/index.ts
  • Extension modules: discovered under extensions/ (+ legacy settings.json.extensions string array)
  • Extensions: extensions/<name>/gemini-extension.json
  • Settings: config.yml (plus the one-time settings.json migration)

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 profile config.yml, the machine-global bindings, CLI --config overlays, 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.md keeps the bundled title prompts.
  • Discovery checks project config bases first, including .veyyon/TITLE_SYSTEM.md, then the active profile’s agent/TITLE_SYSTEM.md and 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-prompt or --append-system-prompt for a one-run override, or PROMPT_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). A TITLE_SYSTEM.md override 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.ts loads via loadCapability(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/.json tool 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:

  1. Source directory ordering from config.ts determines candidate path order.
  2. Capability provider priority determines cross-provider precedence.
  3. Capability key dedup determines collision behavior (first wins for keyed capabilities).
  4. 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

  • ConfigFile JSON -> YAML migration for YAML-targeted files.
  • Settings migration from settings.json and agent.db to config.yml.
  • Settings key migrations include queueMode, ask.timeout, flat theme, task.isolation.enabled, legacy task.isolation.mode values, the whole task.* group plus modelRoles.task moving to subagent.*, 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) and skills.customDirectories are no longer read. Skills load only from the active profile. A stale key in an old config.yml is 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)

ModuleRole
packages/coding-agent/src/mcp/Config load, manager, OAuth, tool wiring
packages/coding-agent/src/discovery/builtin.tsProfile-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.

User guide: MCP, MCP setup.

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.

ComponentRole
Provider adaptersPer-provider connection and wire-format adapters
API client registryOpenAI-compatible API client registry
Provider detailsProvider metadata, auth mode, and endpoints
Model catalogModel catalog and per-model capabilities
Model registrySlug 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 streamIdleTimeoutMs bounds 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:

FunctionUse
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

ProviderBound before the first event
OpenAI completions, Responses, OpenRouter, AzurePre-response fence plus the stream watchdog.
AnthropicThe same, and the retry ladder retries one stall and rejects the next once the phase budget is spent.
CodexThe same, on both ladders: fetchWithRetry (no response) and the provider-error reopen (a retryable envelope that is itself a stall).
GitLab DuoOne 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, DevinThe 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

  1. 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.yml or <cwd>/.veyyon/secret-env-keywords.yml adds to the list and cannot remove from it. See Env keyword list.
    • secrets.yml files (see below).
    • Encrypted vault entries selected for the current profile and working directory.
  2. 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.

  1. 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.

  2. Toggling secret protection and running /secret commands 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:

ModeBehaviorReversible
obfuscate (default)Replaced with a named or machine-keyed HMAC placeholderYes, while the entry is live
replaceReplaced with a deterministic safe same-length stringNo

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 obfuscate entry 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: replace for 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 minLength on 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:

TypedParsed 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:

SubcommandPurpose
/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 listAn 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> 7dGive 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:

WordRead byHow it is recognised
an environment variablefrom-env (position 1), value (after from-env)position, and a keyword on value because a variable name is arbitrary text
a nameevery command that takes one entryposition, except on log where it is the non-numeric trailing word
a lifetimefrom-env, extend30m, 12h, 7d, 2w, never, or any digit-leading word
a vaultfrom-env, rm, clear, scope, discardone of profile, project, global. Position on clear, scope and discard; trailing on from-env and rm
a limitlogdigits 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 earlier load(), 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. #scopePathOwner resolves the owner by file identity and the refusal states it, because an operator told only “cannot discard” uses rm on 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

ScopePath
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:

ConditionBehavior
No vault fileEmpty. Nothing was stored.
Vault present, key missingHard error. Never read as an empty vault.
Key of wrong lengthHard error, so a new key is not written beside a recoverable one.
Unsafe key directory, key file, or vault permissionsHard 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 pathHard error. The path is never followed or shared.
Ciphertext, nonce, authentication tag, scope, canonical path, or physical scope identity modifiedHard error. GCM authenticates the complete envelope and its location.
Legacy version 1 envelopeHard error directing the operator to re-add the entry in the bound current format.
Unknown envelope versionHard error advising an upgrade rather than deletion.
Entry name or value contains ill-formed UTF-16Hard 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, hasNamedSecret and knowsPlaceholder, so no path reaches a value without passing it.
  • #nextExpiryAt caches 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 onExpiry with explicit persisted-deletion state. sdk.ts renders 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.
  • addNamedSecret takes the deadline, so /secret extend moves the moment substitution stops.
  • #forgetPlaceholder is 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.

FieldMeaning
atEpoch milliseconds at expansion.
secretsPlaceholders substituted, in order of appearance, deduplicated.
toolTool that received them.
sessionSession 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.
commandThe arguments as the model produced them, JSON-encoded.
truncatedtrue when command was cut to fit the byte cap.
omittedSecretsNumber 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 to secret-audit.jsonl.1 and starts a fresh one, keeping two generations. The same cross-process lock covers both sessions that race at the boundary. read spans both generations, so /secret log 20 immediately 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 missing tool printed undefined in 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() awaits flush(), 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.

CandidateDecisionReason
PASSPHRASEaddedThe 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.
APIKEYno entry neededKEY at the end of a name already matches it.
PRIVKEYno entry neededSame.
SECRETKEYno entry neededSame.
PWDrejectedThe 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:

LevelPathPurpose
Profile~/.veyyon/profiles/default/agent/secrets.yml (active agent dir)Profile-wide secrets
Project<cwd>/.veyyon/secrets.ymlProject-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:

FieldTypeRequiredDescription
type"plain" or "regex"YesMatch strategy
contentstringYesThe secret value (plain) or regex pattern (regex)
mode"obfuscate" or "replace"NoDefault: "obfuscate"
replacementstringNoCustom replacement (replace mode only)
flagsstringNoRegex flags (regex type only)
minLengthpositive integerNoShortest 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, reader
  • packages/coding-agent/src/secrets/env-keywords.ts + env-keywords.yml – the Tier B keyword list and the boundary rule, one owner
  • packages/coding-agent/src/secrets/index.ts – loading, merging, env var collection, refusal of unprotectable entries
  • packages/coding-agent/src/secrets/obfuscator.tsSecretObfuscator, message obfuscation, runtime add/forget
  • packages/coding-agent/src/secrets/placeholder.ts – both placeholder forms and the rule keeping them apart
  • packages/coding-agent/src/secrets/policy.ts – the length rules and the rejection type, defined once
  • packages/coding-agent/src/secrets/regex.ts – regex literal parsing and compilation
  • packages/coding-agent/src/secrets/secret-command.ts/secret logic, pure and session-free
  • packages/coding-agent/src/secrets/scope-move.tsplanScopeMove: the two refusals that make a scope move safe to perform as add-then-remove
  • packages/coding-agent/src/secrets/vault.ts – entries, lifetimes, scopes, the store
  • packages/coding-agent/src/secrets/vault-crypto.ts – the key, the seal, and the threat model
  • packages/coding-agent/src/slash-commands/helpers/secret.ts – the session-bound adapter shared by the TUI and text/ACP paths
  • packages/coding-agent/src/system-prompt-builder/section-registry.ts – the runtime section row that puts the inventory of spendable names in the base system prompt
  • packages/coding-agent/src/session/operator-notices.ts – the one channel for a warning that must reach a person
  • packages/tui/src/components/input.tsInput.mask and maskValue, the one place a value becomes visible text
  • packages/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):

ValueWhat it is
offNo memory subsystem runs.
localLocal rollout-summarisation pipeline described on this page (MEMORY.md / memory_summary.md / generated skills).
mnemopiLocal SQLite recall/retain backend with optional embeddings; the agent uses the recall, retain, and reflect tools. mnemopi.* settings tune it.
hindsightVectorize 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:

URLContent
memory://rootCompact summary injected at startup
memory://root/MEMORY.mdFull long-term memory document
memory://root/skills/<name>/SKILL.mdA generated skill playbook

/memory slash command

SubcommandEffect
viewShow the current backend injection payload
statsShow backend-specific memory statistics, when supported
diagnoseShow backend-specific diagnostics, when supported
clear / resetDelete active backend memory data/artifacts
enqueue / rebuildForce consolidation/retention work for the active backend
mm listList 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 seedCreate any built-in mental models that are missing
mm delete <id>Delete a mental model from the bank
mm reloadRe-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 document
  • memory_summary.md: the compact text injected at session start
  • skills/: 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/.

FilePurposeVariables
stage_one_system.mdSystem prompt for per-session extractionn/a
stage_one_input.mdUser-turn template wrapping session content{{thread_id}}, {{response_items_json}}
consolidation_system.mdSystem prompt for cross-session consolidationn/a
consolidation.mdUser-turn prompt for cross-session consolidation{{raw_memories}}, {{rollout_summaries}}
read-path.mdMemory guidance injected into live sessions{{memory_summary}}, {{learned}}

Model selection

Memory piggybacks on the model role system.

PhaseRolePurpose
Phase 1 (extraction)defaultPer-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

SettingDefaultDescription
memory.backendoffSelect local for this pipeline; legacy memories.enabled: true is migrated to memory.backend: local when no explicit backend is set
memories.maxRolloutAgeDays30Sessions older than this are not processed
memories.minRolloutIdleHours12Sessions active more recently than this are skipped
memories.maxRolloutsPerStartup64Cap on sessions processed in a single startup
memories.summaryInjectionTokenLimit5000Max 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 /memory command routes here via packages/coding-agent/src/memory-backend/local-backend.ts)
  • packages/coding-agent/src/memories/storage.ts: SQLite-backed job queue and thread registry
  • packages/coding-agent/src/prompts/memories/: memory prompt templates
  • packages/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 /tree navigation.

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.ts
  • packages/agent/src/compaction/pruning.ts
  • packages/agent/src/compaction/utils.ts
  • packages/coding-agent/src/session/session-manager.ts
  • packages/coding-agent/src/session/agent-session.ts
  • packages/coding-agent/src/session/messages.ts
  • packages/coding-agent/src/extensibility/hooks/types.ts
  • packages/coding-agent/src/config/settings-schema.ts

Session entry model

Compaction and branch summaries are first-class session entries, not plain assistant/user messages.

  • CompactionEntry
    • type: "compaction"
    • summary, optional shortSummary (display only, and no longer produced by compact(): see “Short summary” below)
    • firstKeptEntryId (compaction boundary)
    • tokensBefore
    • optional details, preserveData, fromExtension
  • BranchSummaryEntry
    • type: "branch_summary"
    • fromId, summary
    • optional details, fromExtension

When context is rebuilt (buildSessionContext):

  1. Latest compaction on the active path is converted to one compactionSummary message.
  2. Kept entries from firstKeptEntryId to the compaction point are re-included.
  3. Later entries on the path are appended.
  4. branch_summary entries are converted to branchSummary messages.
  5. custom_message entries are converted to custom messages.

convertToLlm() transforms these custom roles into LLM-facing messages, through these static templates:

  • packages/agent/src/prompts/compaction/compaction-summary-context.md
  • packages/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:

  1. Manual context compaction: /compact [summary] [focus] calls AgentSession.compact(...).
  2. Automatic overflow recovery: after a same-model assistant error that matches context overflow.
  3. Automatic incomplete-output recovery: after a same-model assistant message ends with stopReason === "length" (OpenAI/Codex response.incomplete).
  4. Automatic threshold maintenance: after a successful turn when context exceeds the resolved threshold.
  5. Mid-turn threshold maintenance: before the next provider request when a tool-loop turn crosses the threshold and compaction.midTurnEnabled !== false.
  6. 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" and willRetry: 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" and willRetry: true.
    • On context-full success, agent.continue() is scheduled to retry the turn.
  • 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" and willRetry: false.
    • On success, if compaction.autoContinue !== false, post-turn maintenance schedules an agent-authored developer auto-continue prompt from prompts/turn-control/auto-continue.md; mid-turn maintenance never schedules a separate continuation because the core loop already defines the next provider request.
  • Idle maintenance

    • Trigger: runIdleCompaction() when not streaming or already compacting.
    • Uses reason: "idle" and does not auto-continue afterward.

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 (in packages/agent/src/compaction/legacy-snapcompact-archive.ts) reads the archived source from CompactionEntry.preserveData.snapcompact and 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):

  1. Stale-result pass (#pruneStaleToolResultspruneSupersededToolResults) runs first, before any threshold gating, so it fires even with compaction.enabled off. It is skipped entirely when both compaction.supersedeReads and compaction.dropUseless are false.
  2. Threshold prune (#pruneToolOutputspruneToolOutputs) runs only on the threshold path, after the compaction.enabled / strategy check and after error turns are skipped, and only once the turn has usable usage data. Its savings feed postMaintenanceContextTokens, which is the trigger figure reported to the compaction it may schedule.

Default prune policy:

  • Protect newest 40_000 tool-output tokens.
  • Require at least 20_000 total estimated savings.
  • Never blank a result below 50 tokens (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 skill tool results, read results of skill:// paths, or reads of the active plan reference file (added via AgentSession’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 by compaction.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 receive USELESS_NOTICE instead of the token-count placeholder.
  • Summary serialization: serializeConversation drops 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).

  1. Find previous compaction index.
  2. Compute boundaryStart = prevCompactionIndex + 1.
  3. Adapt keepRecentTokens using measured usage ratio when available.
  4. Run findCutPoint() over the boundary window.

Valid cut points include:

  • message entries with roles: user, assistant, bashExecution, hookMessage, branchSummary, compactionSummary
  • custom_message entries
  • branch_summary entries

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_message entry
  • branch_summary entry

Split-turn compaction generates two summaries:

  1. History summary (messagesToSummarize)
  2. 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:

  1. Convert messages via convertToLlm().
  2. Serialize with serializeConversation().
  3. Wrap in <conversation>...</conversation>.
  4. Optionally include <previous-summary>...</previous-summary>.
  5. Optionally inject extension hook context and active memory-backend compaction context as <additional-context> entries.
  6. 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 explicit generateHandoff(...), 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.remoteEndpoint is 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/completions receive { model, messages, stream: false }, where messages contains one system prompt and one user prompt. The summary is read from choices[0].message.content, which lets self-hosted servers such as llama.cpp and vLLM act as summarizers without a separate shim.
  • 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 set
  • write(path) → modified set
  • edit(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.readFiles excludes files also modified; details.modifiedFiles contains 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:

  1. Appends a CompactionEntry with appendCompaction(...).
  2. Rebuilds display context from the active leaf via buildDisplaySessionContext().
  3. Replaces live agent messages with rebuilt context.
  4. Synchronizes active todo phases from the rebuilt branch and closes provider sessions whose history was rewritten.
  5. Emits session_compact hook event.

Branch summarization pipeline

Branch summarization is tied to tree navigation, not token overflow.

Trigger

During navigateTree(...):

  1. Compute abandoned entries from old leaf to common ancestor using collectEntriesForBranchSummary(...).
  2. If caller requested summary (options.summarize), generate summary before switching leaf.
  3. 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:

  1. First pass: collect cumulative file ops from all summarized entries, including prior pi-generated branch_summary details.
  2. Second pass: walk newest → oldest, adding messages until token budget is reached.
  3. Prefer preserving recent context.
  4. 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:

  1. Converts and serializes selected messages.
  2. Wraps in <conversation>.
  3. Uses custom instructions if supplied, otherwise branch-summary.md.
  4. Calls summarization model with SUMMARIZATION_SYSTEM_PROMPT.
  5. Prepends branch-summary-preamble.md.
  6. 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: ...
  • 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 = true
  • compaction.strategy = "summary", the sole strategy. Every stored legacy strategy token migrates to summary; legacy off also sets compaction.enabled: false. Use /handoff for an explicit transfer to a new session.
  • compaction.reserveTokens = unset (absent key). When unset the compaction layer falls back to DEFAULT_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 = 10000
  • compaction.supersedeReads = true (drop earlier file reads that a later read of the same file makes redundant)
  • compaction.dropUseless = true
  • compaction.handoffSaveToDisk = false (also write the handoff packet to disk)
  • compaction.modelContextWindow = unset (absent key); overrides the window size the compaction budget resolves against
  • compaction.autoContinue = true
  • compaction.midTurnEnabled = true
  • compaction.remoteEndpoint = undefined
  • compaction.threshold = auto; the one trigger setting, with its unit in the value. auto is contextWindow - max(15% of contextWindow, reserveTokens). 85% is a percent of the current model’s window. 170000 is 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 to contextWindow - 1 with a one-time warning (never silently reinterpreted). Resolution and the migration off the two retired keys live in packages/agent/src/compaction/threshold.ts.
  • compaction.thresholdTokens = -1 and compaction.thresholdPercent = -1; retired. The global config is rewritten on load (#migrateRawSettings): a positive amount becomes threshold: <amount>, a positive percent becomes threshold: <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, --config overlays — are folded in at read time by withLegacyCompactionThreshold with the same precedence, and the session reports which retired key supplied the value.
  • compaction.idleEnabled = false
  • compaction.idleThresholdTokens = 200000
  • compaction.idleTimeoutSeconds = 300
  • branchSummary.enabled = false
  • branchSummary.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

Modectx.ui.custom(...) availabilityNotes
Interactive TUISupportedComponent is mounted in the editor area or overlay, focused, and must call done(result) to resolve.
Background/headlessNot interactiveUI context is no-op (hasUI === false).
RPC modeNot mountedcustom() 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:

  1. Do not intentionally exceed width on any line. The renderer truncates overwide non-image lines as a last-resort guard, but components should still return width-safe output.
  2. Measure visual width, not string length: use visibleWidth().
  3. Truncate/wrap ANSI-aware text with truncateToWidth() / wrapTextWithAnsi().
  4. 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 through TUI.showOverlay(...); without overlay, it replaces the editor component area directly.
  • Overlay custom UI is anchored at bottom-center with full terminal width/max height and is removed through the returned overlay handle when done(...) 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): calls component.dispose?.(), hides the overlay if present, restores editor + text for non-overlay flows, focuses editor, resolves promise. So done(...) 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: boolean
  • isPartial: boolean
  • spinnerFrame?: 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 CancellableLoader with AbortSignal and call done(...) from onAbort.

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: mounting renderCall/renderResult components 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, strict additionalProperties).
  • 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.

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

StepWhat it does
SeamRuns at tool dispatch, before schema validation
Fix-if-clearTrailing commas, parse sentinels (__parseError / __rawJson), stringified JSON objects
Refuse-if-ambiguousMissing required strings with multiple plausible sources → unrepairable
Alias / typo renameUnknown keys that clearly map to a declared property are renamed; ambiguous renames are rejected
Strict unknown keysSchemas with additionalProperties: false reject leftover keys after alias resolution
Size boundInputs over 1 MiB are not repaired
DisableVEYYON_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

The repair cascade

Before argument validation, the agent loop runs packages/coding-agent/src/repair/schema-repair.ts in this order:

  1. Parse leniency: trailing commas / relaxed JSON; stringified argument blobs.
  2. Alias / typo key rename: unknown keys that match a common alias (filepathpath, contentscontent) 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).
  3. 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.
  4. Ambiguity guard: reject when required string fields have multiple plausible donors.
  5. Outcome: clean, repaired (canonical args + hints), or unrepairable (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.

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
FieldEffect
repair: falseSkip 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

  • /usage in the TUI and veyyon stats on 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.

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

  1. read or search records a whole-file snapshot and prints [relative/path#TAG] plus LINE:content rows (TAG is a four-hex snapshot id).
  2. The model sends edit with an input string: 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 ops SWAP.BLK / DEL.BLK / INS.BLK.POST, plus whole-file REM and MV DEST).
  3. @veyyon/hashline parses, verifies the tag against the snapshot store, applies ops, and returns a fresh [path#TAG] header plus a compact diff preview.
  4. write can 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

PropertyBehavior
Stale anchorMismatch errors name the tag; snapshot recovery can suggest the current file hash
Line numbers1-indexed; body rows use +TEXT prefix
OrderNon-overlapping hunks; overlapping regions fail with an error
EncodingApplies to normalized content; BOM and dominant line ending preserved on write

Further reading

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:

  1. Schema repair attempts JSON recovery and ambiguity refusal at the agent-loop seam.
  2. If repair succeeds, arguments proceed to hashline / apply_patch validation and dispatch.
  3. If repair cannot disambiguate, the loop returns an error tool result with hints (no dispatch).
  4. 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:

  1. Bundled pi-catalog models
  2. ~/.veyyon/profiles/default/agent/models.yml custom providers and models
  3. Runtime discovery (Ollama, LM Studio, discovery-enabled gateways)
  4. 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):

  1. CLI --api-key (ephemeral)
  2. models.yml apiKey on a custom provider
  3. Stored API key / OAuth in the agent auth store (~/.veyyon/profiles/default/agent/agent.db)
  4. Provider environment variables (see docs/handbook/src/reference/providers.md)
  5. 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) and packages/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, and requirePromptFrom, 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, and splitBanneredDocument, 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:

PackagePrompts directoryWhat is in it
@veyyon/coding-agentpackages/coding-agent/src/prompts/the system prompt, the subagent prompt, tool descriptions, and every turn the agent takes on its own behalf
@veyyon/agent-corepackages/agent/src/prompts/compaction: summarizing a session, branch summaries, handoff documents
@veyyon/aipackages/ai/src/prompts/one format guide per tool-call dialect, plus the tool-catalog template that contains them
@veyyon/hashlinepackages/hashline/src/the hashline patch language, which is the edit tool’s description
@veyyon/evalspackages/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.

InputSourceEffect
--system-prompt <text-or-file>CLI flagReplaces block 0: the default stable instructions. Highest precedence.
--append-system-prompt <text-or-file>CLI flagAdds 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.md scaffold 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-prompt replaces 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-prompt without 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.


“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

GoalUse
Add an instruction on top of the full default promptAGENTS.md (profile, global, or beside the code)
Change one section and keep the restPROMPT_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 titlesTITLE_SYSTEM.md; the agent’s own prompt does not affect title generation
Use {{cwd}} / {{date}} / other internals in my fileNot supported. Caller-supplied prompts are inserted verbatim.
See the prompt a configuration actually producesveyyon prompt (see section 9)
Change instructions per repositoryAn AGENTS.md in that repository
Change instructions everywhereThe 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:

FileEffect
<section>.append.mdYour text is added at the end of that section. The shipped text stays, including anything added to it in a later release.
<section>.mdYour 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 --sections prints: conventions, role, runtime, tool-policy, execution-workflow, delivery-contract. The systemPrompt.sectionOverrides config 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 NAME and ============== 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 computed without giving computedText an 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 .md outside 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 dir off the descriptor. Four of them used to type the path themselves and one had gone stale.
  • A row whose purpose states 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 textBelongs in the template?
Structure: headings, ordering, the shape of a listYes
A fact about this session ({{cwd}}, the tool names, the concurrency cap)Yes, as a variable
A behavior a setting decidesNo — a {{#if}} on that setting’s gate
A behavior nothing decides, stated as a rule the operator cannot see or changeNo — 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:

SettingWhat changes in the prompt
personalitythe personality block, or nothing when set to none
tui.renderMermaidwhether the model is told Mermaid fences render as terminal diagrams
subagent.enabledthe whole Delegation section, which is absent when subagents are off
subagent.delegationwhether the section requests delegation, and whether it uses MUST/ONLY wording
subagent.batchwhich call shape the delegation guidance teaches
subagent.maxConcurrencythe concurrency limit quoted in that guidance
subagent.maxNestedSpawnDepththe IRC coordination clause, present only when this session can spawn
subagent.agentswhich specialists delegation prose names
includeModelInPromptwhether the active model is surfaced in the workstation block
tools.formatwhether tools are described inline or left to the provider’s tool list
inlineToolDescriptorswhether descriptors live in the prompt or provider schemas for the active model
tools.intentTracingwhether 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:

SettingWhy
includeWorkspaceTreeread 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:

GateOmittedA default session
eagerTasksfalse, no delegation asktrue, because subagent.delegation ships as preferred
taskIrcEnabledfalse, no coordination clausetrue, because the recursion limit allows spawning
subagentNames[], prose lists no specialistthe agents this session can spawn
taskMaxConcurrency0, quote no cap32, 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 statementContext it 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:

ConditionMeaningTemplate shape it replaces
alwaysin every promptplain text
whena variable is truthy{{#if x}}
whenContainsa collection holds a member, such as a tool being active{{#has tools "task"}}
whenAllevery nested condition holdsnested {{#if}} blocks
whenAnyany nested condition holds{{#ifAny a b}}
notthe nested condition does not holda 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.md contains exactly the {{templateSections}} variable and no literal prose, condition, or banner.
  • Every static section declared by section-registry.ts owns 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 buildSystemPrompt output 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}, where path can 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), or src/foo.ts:5-16,960-973 (multiple ranges in one call). :raw reads 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, and DEFAULT_MAX_LINES (3000) at the ceiling. The byte cap is tools.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. read is 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 than tools.outputMaxColumns are 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, via archive.zip:path/inside), SQLite databases (file.db:table, with pagination and where/order filters), 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:

  1. 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.
  2. 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($$$)").

Type-specific options and validation

Options are strictly validated per type; cross-type fields are rejected with an actionable error:

  • type: "files" accepts hidden, gitignore, and limit:
    • hidden (default true) includes dotfiles.
    • gitignore (default true) respects .gitignore rules; set false to search ignored paths.
    • limit (default 200, max 200) bounds returned paths; output is sorted by mtime descending and grouped under # <dir>/ directory headers.
  • type: "text" accepts path, case, paths, gitignore, and skip:
    • path scopes the search (file, directory, glob, internal URL like veyyon://, 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 (default true) toggles case sensitivity.
    • gitignore (default true) respects .gitignore.
    • paths returns the matching file paths with per-file match counts in place of match lines, the shape rg -l produces.
    • skip pages past already-returned files; results are paginated at 20 files per call (DEFAULT_FILE_LIMIT) with an internal cap of 2000 matches. Context lines around matches are governed by search.contextBefore (default 1) and search.contextAfter (default 1). A single-file scope returns at most 200 matches (SINGLE_FILE_MATCHES) and a multi-file scope at most 20 per 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; skip pages files rather than matches, so passing it reaches nothing past a per-file cap. The 2000-match ceiling is the one limit that leaves files unopened, so only it marks the file total a floor.
  • type: "structure" accepts path and skip:
    • path scopes the search (file, directory, glob, local or materialized internal URL, or semicolon-delimited list). ssh:// is not supported; inspect remote code with read before structural matching.
    • skip specifies match offset for pagination (default limit 50 matches).
    • Metavariable syntax supports $NAME (single node), $_ (anonymous node), $$$NAME (multi-node sequence), and $$$ (anonymous sequence). Each match lists its bindings on a meta: line; a value over 60 bytes (META_VALUE_MAX_BYTES) or containing a newline renders as KEY=…, 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 skip value that continues them. limit is 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, default 1 (lines of context before each text match).
  • search.contextAfter: number, default 1 (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.ts imports 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 edit for surgery. The tool description tells the model to prefer edit for a surgical change to an existing file, keeping write from 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-in Bun.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 \n and \t, drop the rest. The control regex covers C0 (excluding tab/newline), \r, DEL, and the C1 range; \n and \t are 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, and openrouter. They have nothing to do with context files except that both kinds of id share the one disabledProviders list: 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.

FileScopeBehavior
~/.veyyon/AGENTS.mdGlobal UserGlobal cross-profile context for every session across all profiles.
~/.veyyon/profiles/<profile>/...Profile UserActive 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.md
3. ~/.veyyon/profiles/<profile>/agent/agent.md
4. ~/.veyyon/profiles/<profile>/agent.md (Lowest)
<ancestor>/.veyyon/AGENTS.mdProjectProject 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.mdUserUser-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 its AGENTS.md; every other level contributes a bare AGENTS.md, falling back to a bare CLAUDE.md when no AGENTS.md has 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 empty AGENTS.md file 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 is repo/packages/api/.veyyon/AGENTS.md (the nearest non-empty .veyyon/ directory). repo/.veyyon/AGENTS.md is not also included, though a bare repo/AGENTS.md beside 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 idConvention pathScopeNotes
native.veyyon/AGENTS.mdUser + projectRecommended 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.mdUser + projectUser file ~/.claude/CLAUDE.md; project file <cwd>/.claude/CLAUDE.md only (no ancestor walk-up).
codex.codex/AGENTS.mdUserUser 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.mdUserUser file ~/.gemini/GEMINI.md only.
opencode.config/opencode/AGENTS.mdUserUser file ~/.config/opencode/AGENTS.md only.
github.github/copilot-instructions.mdUserUser-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.mdUserUser files from ~/.agent/ and ~/.agents/ only; there is no project scope.
agents-mdAGENTS.mdProjectStandalone (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.mdUser rulesGitHub 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:

PriorityProvider id
100native
80claude
70agents, codex
60gemini
55opencode
30github
10agents-md

Discovered files are then deduplicated by scope:

  • One user context file is kept across all providers. Because native has the highest priority, ~/.veyyon/profiles/<profile>/agent/AGENTS.md shadows 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, native picks a single file before any shadowing happens. The order is .veyyon/AGENTS.md (only from the nearest non-empty .veyyon/ directory), then a bare AGENTS.md, then a bare CLAUDE.md. The first one that has content wins and the rest of that directory’s candidates are never read, so a CLAUDE.md beside an AGENTS.md is not loaded, not appended, and not deduplicated later. CLAUDE.md is last because AGENTS.md is the tool-neutral convention: a project containing both is nearly always stating the same rules twice, and a stale CLAUDE.md must not contradict a maintained AGENTS.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.md and a package directory with only CLAUDE.md both 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.md and 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.md last 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.md files load through native (priority 100): repo/AGENTS.md at depth 2 and repo/packages/api/AGENTS.md at 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.md and loads as the project context file at its depth; repo/.veyyon/AGENTS.md is 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 @token without expanding it.
  • [email protected]:org/repo.git and [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. imports notes/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 @token text 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. A RULES.md anywhere 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 kindExamplesEffect when listed
Discovery provider idsnative, claude, codex, gemini, opencode, github, agents, agents-mdThe 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 idsanthropic, openai, google, groq, ollama, openrouterThe 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.md or CLAUDE.md at any ancestor is loaded by native itself; agents-md contributes only when native is disabled. A CLAUDE.md is skipped when the same directory has a usable AGENTS.md or .veyyon/AGENTS.md; that is deliberate, see Load order and shadowing.
  • .claude/CLAUDE.md is read only from the current working directory, not from every ancestor. .gemini/GEMINI.md and .github/copilot-instructions.md are user-level only; a repository’s copies are not read.
  • ~/.codex/AGENTS.md and ~/.config/opencode/AGENTS.md are 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 disabledProviders across your profile and --config layers.

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.continuationModes allows
  • Optional token budgets when goal.modelBudgetsEnabled is 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.
    • 170000 is 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-model keeps 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-only stops 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 task tool (packages/coding-agent/src/task/executor.ts). /agents opens 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 under subagent, plus compaction.model. default is 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.enabled and related settings, in packages/coding-agent/src/advisor/) is a background continuous-review mechanism rather than a mode you invoke; /advisor reports and configures it. See docs/handbook/src/features/advisor.md.
  • Addressed inter-agent messaging via the irc tool (packages/coding-agent/src/tools/irc.ts, packages/coding-agent/src/irc/bus.ts): send/wait/inbox/list ops over a process-global bus. send is 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 (or send await:true) observes the recipient’s reply as a real turn. Gated by isIrcEnabled: 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/stats when 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 under body; anything else is stored as bodyText, or as bodyBase64 when 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.

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

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:

CheckWhat it proves
veyyon on PATHThe shell can find it, and which file it found.
PATH copiesOnly 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 runsIt executes and reports the version you are running. If it will not start, the check quotes the system error text.
Native addonA real search returns a real match, so the native addon loaded. --version alone passes without it.
Install methodWhether veyyon update swaps the binary or advances a source checkout.
vey aliasThe short name used throughout the documentation resolves.
Shell completionsCompletion 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

  1. veyyon setup status when veyyon itself is misbehaving: it covers the install and your credentials.
  2. veyyon plugin doctor when an extension is misbehaving.
  3. /debug and /memory diagnose inside a session.
  4. 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_patch patch format and parts of the agent-loop shape as an independent TypeScript reimplementation, see NOTICE for 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; the apply_patch parser 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 Patch envelope. Default edit mode is hashline via the edit tool. 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 (legacy always-askask, write and auto-editask-command). There is no OS command sandbox; the mode, per-tool tools.approval overrides, 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, yolo included, and the merely dangerous ones prompt on every rung below it.

  • model catalog: Bundled provider/model data plus models.yml / models.yaml custom entries. There is no separate backends.toml subsystem.

  • 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 edit and write tools change files on disk. Default edit is hashline (content-hash anchors); write creates or overwrites a whole file. Both respect tools.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 as tool_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, and none.

  • plugin: A directory with a .claude-plugin/plugin.json manifest (or a package.json containing the veyyon manifest) that can add skills, MCP servers, hooks, and related assets. Plugins are discovered through marketplaces, npm installs, or veyyon plugin link.

  • profile: A directory under ~/.veyyon/profiles/<name>/ (including default) 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 id and a parentId. Branching moves the in-memory leaf; the next appended entry’s parentId (and an optional branch_summary entry) 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 via skill://.

  • 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.