Appearance
Configure
VeloxSaarthi is configured via ~/.vlx/vlx.yaml. The normal way to create it is the guided setup — vlx init writes a complete config (and the secrets file) from your answers. Use vlx.example.yaml in the repo as a commented reference, or as a starting point when writing the file by hand:
bash
cp vlx.example.yaml ~/.vlx/vlx.yaml # then replace the placeholder valuesSet VLX_CONFIG to use a different location. When hand-writing the file, use absolute paths for runtime.source_repo_path and runtime.worktree_root — the daemon can be started from any directory, so CWD-relative values are fragile.
The schema is validated at startup by a strict Zod parser. Unknown keys are rejected. See src/config/schema.ts for the authoritative definition.
Config versions
vlx init now writes the v2 layout: a top-level projects: list where each entry carries its own source_control (org, tracker/scm project, repo, pat_env) and an explicit runtime block (worktree_root, source_repo_path, base_branch). The flat v1 sections documented below (ado:, azure_repos:, per-chat telegram:) are legacy — existing v1 files are auto-migrated in memory at startup and keep working. See Multi-project daemon for the v2 project entry shape.
ado — Azure DevOps tracker
yaml
ado:
org_url: "https://dev.azure.com/<org>"
project: "<project>"
pat_env: "ADO_PAT"
# bot_assignee: "saarthi@veloxcore.com"| Key | Required | Description |
|---|---|---|
org_url | Yes | ADO organisation URL. |
project | Yes | ADO project name (case-sensitive). |
pat_env | Yes | Name of the env var holding the ADO PAT (not the token value). The PAT must have Work Items R/W and Code R/W scopes. |
bot_assignee | No | Email of the ADO user stories must be assigned to. Omit to default to the PAT owner (@Me). Set when the PAT user differs from the bot assignee (e.g., a service account). |
github — GitHub tracker / SCM host (alternative)
Use this section when adapters.tracker: "github" or adapters.scm_host: "github". One section serves both adapter slots.
yaml
github:
owner: "veloxcore"
repo: "veloxsaarthi"
token_env: "GITHUB_TOKEN"
# bot_login: "saarthi-bot"
# default_branch: "main"| Key | Required | Description |
|---|---|---|
owner | Yes | GitHub organisation or user owning the repository. |
repo | Yes | Repository name. |
token_env | Yes | Name of the env var holding the GitHub token. Required scopes: repo (full), read:org. |
bot_login | No | GitHub login stories must be assigned to. Defaults to the token owner. |
default_branch | No | PR target branch for the SCM host adapter. Defaults to main. |
jira — Jira Cloud tracker (alternative)
Use when adapters.tracker: "jira".
yaml
jira:
base_url: "https://yourorg.atlassian.net"
project_key: "VLX"
email_env: "JIRA_EMAIL"
api_token_env: "JIRA_API_TOKEN"
# bot_account_id: "5b10ac8d82e05b22cc7d4ef5"
# actionable_statuses: ["To Do", "Open"]
# statuses:
# active: "In Progress"
# resolved: "In Review"
# closed: "Done"| Key | Required | Description |
|---|---|---|
base_url | Yes | Jira Cloud base URL. |
project_key | Yes | Jira project key (e.g. VLX). Stories in this project are polled. |
email_env | Yes | Name of the env var holding the Atlassian account email. |
api_token_env | Yes | Name of the env var holding the Atlassian API token. |
bot_account_id | No | Poll issues assigned to this Jira accountId. Defaults to the API token owner. |
actionable_statuses | No | Issue statuses the agent will pick up. Defaults to ["To Do", "Open"]. |
statuses | No | Map domain statuses (active, resolved, closed) to Jira transition names. |
telegram — Human channel
yaml
telegram:
bot_token_env: "TELEGRAM_BOT_TOKEN"
group_chat_id_env: "TELEGRAM_GROUP_CHAT_ID"
authorized_user_ids: [123456789]
# state_file: "~/.vlx/telegram-state.json"| Key | Required | Description |
|---|---|---|
bot_token_env | Yes | Env var name holding the Telegram bot token. |
group_chat_id_env | Yes | Env var name holding the group chat ID. Must be a forum-mode supergroup (Topics enabled). Numeric; negative for groups; -100-prefixed for supergroups. |
authorized_user_ids | Yes | Array of Telegram user IDs allowed to interact with the bot. Unauthorised updates are silently ignored. |
state_file | No | Path to the JSON file persisting the getUpdates offset and topic cache across daemon restarts. Defaults to ~/.vlx/telegram-state.json. |
Deriving the chat ID:
Add the bot to the group, then call https://api.telegram.org/bot<token>/getUpdates. Look for the chat.id field in the response. For supergroups the value is negative and starts with -100.
teams — Microsoft Teams human channel (alternative)
Use when adapters.human_channel: "teams".
yaml
teams:
tenant_id: "00000000-0000-0000-0000-000000000000"
client_id: "00000000-0000-0000-0000-000000000000"
client_secret_env: "TEAMS_CLIENT_SECRET"
team_id: "00000000-0000-0000-0000-000000000000"
channel_id: "19:xxxxxxxx@thread.tacv2"
authorized_user_ids: ["00000000-0000-0000-0000-000000000000"]| Key | Required | Description |
|---|---|---|
tenant_id | Yes | Azure AD tenant ID. |
client_id | Yes | App registration client ID. The app needs ChannelMessage.Send, Chat.ReadWrite, TeamsAppInstallation.ReadWriteForTeam Microsoft Graph permissions. |
client_secret_env | Yes | Env var name holding the client secret. |
team_id | Yes | ID of the Teams team. |
channel_id | Yes | Full channel ID (thread format). |
authorized_user_ids | Yes | AAD object IDs allowed to interact. The bot fails closed when this list is empty. |
web — Local dashboard human channel (alternative)
Use when adapters.human_channel: "web". A localhost-only control dashboard embedded in the daemon process — it is up whenever the daemon is up, needs no third-party platform, and replaces Telegram/Teams for status and operator actions. Useful where Telegram/Teams are blocked or unavailable.
yaml
web:
enabled: true
port: 8787
# token: "a-stable-secret" # optional; auto-generated and logged if omitted| Key | Required | Description |
|---|---|---|
enabled | No | Start the embedded dashboard. Defaults to true (the server starts unless explicitly set to false), independent of which human_channel is chosen. |
port | No | TCP port bound on 127.0.0.1 only — never exposed on other interfaces. Defaults to 8787. |
token | No | Secret guarding the /api/* routes (query ?token= or x-vlx-token header). Omit and one is generated with crypto.randomUUID() at startup. |
On daemon start the dashboard logs a ready-to-click URL: http://127.0.0.1:8787/?token=…. Open it to see live runs and act on them (approve/reject, answer clarifications, requeue/cancel/restart/respin/vetomerge) — the same actions Telegram offers. Localhost only: no remote/phone access, no TLS, no multi-user auth in this version. Treat the token like a password.
azure_repos — SCM host (default)
yaml
azure_repos:
org_url: "https://dev.azure.com/<org>"
project: "<project>"
repository_id: "<uuid>"
repo_name: "<repo>"
pat_env: "ADO_PAT"| Key | Required | Description |
|---|---|---|
org_url | Yes | ADO organisation URL (same as ado.org_url typically). |
project | Yes | ADO project name. |
repository_id | Yes | Repository UUID (visible in the ADO repository settings URL). |
repo_name | Yes | Repository name (used for display and branch operations). |
pat_env | Yes | Env var name for the PAT. The same ADO_PAT covers both ADO API and git push over HTTPS. |
runtime — Worktree and pipeline settings
yaml
runtime:
worktree_root: ".vlx/worktrees"
source_repo_path: "."
base_branch: "main"| Key | Required | Description |
|---|---|---|
worktree_root | No | Root directory for per-run git worktrees. Relative to the CWD where the daemon runs. Defaults to .vlx/worktrees. |
source_repo_path | No | Path to the source git repo the daemon manages. Defaults to . (CWD). |
base_branch | No | Base branch new story branches are forked from. Defaults to main. |
Per-project override: each entry under projects: may carry its own runtime block with any subset of these keys; unset keys fall back to the top-level section.
Multi-project daemon
One daemon serves all projects with active: true (or active omitted) concurrently — each project gets its own intake/claim/PR-poll loops, scoped by project, over the shared state DB. Requirements:
- Each project needs its own
runtime.source_repo_path(distinct local checkouts — the daemon refuses to start on duplicates).vlx initalways writes an explicit per-projectruntimeblock (all three keys), so entries never depend on the global fallback. adapters.human_channelmust beweb. Telegram allows only onegetUpdatespoller per bot token and Teams would double-process commands, so for those channels run one daemon per project (separateVLX_HOME) or deactivate all but one project.
Adding a project: cd into the repo's local clone and run vlx init. The folder decides what happens:
- Repo not yet configured → it's added as a new
projects[]entry. Org, project, repo, branch and path are pre-filled from the git remote — mostly just Enter. The previous config is backed up tovlx.yaml.bak. - Repo already configured →
vlx initprints the entry's current values and the config file path to edit; nothing is changed.
Multiple ADO organisations: each project's source_control.pat_env names the env var holding that org's PAT (default ADO_PAT). ADO PATs are org-scoped, so when adding a project from a different org, vlx init asks for that org's PAT and stores it as ADO_PAT_<PROJECT> in the project's secrets file (~/.vlx/secrets/<name>.env). Same-org projects reuse the existing PAT by default. The bot user must exist in every org it serves.
log — Logging
yaml
log:
level: "info"| Key | Required | Description |
|---|---|---|
level | No | Log verbosity: trace / debug / info / warn / error / fatal. Defaults to info. Overridden by the LOG_LEVEL env var. |
permissions — Tool-use gating
Controls which operations the ACP agent can perform without a human tap.
yaml
permissions:
allowed_hosts: []
allowed_registries: []
# approval_timeout_ms: 1500000| Key | Required | Description |
|---|---|---|
allowed_hosts | No | Hostnames the agent may call without a Telegram approval tap (e.g. ["api.github.com", "dev.azure.com"]). Requests to unlisted hosts are posted to Telegram for one-tap approval. |
allowed_registries | No | Package registry URLs the agent may install from without approval (e.g. ["https://registry.npmjs.org"]). |
approval_timeout_ms | No | Milliseconds to wait for a Telegram approval tap before auto-denying. Defaults to 1500000 (25 minutes — just under the watchdog's 30-minute stale-heartbeat threshold). |
approval_gate — Human plan approval (off by default)
When enabled, every plan pauses at the APPROVAL stage and waits for an Approve/Reject tap in Telegram before Build starts. Disabled by default — Plan routes straight to Build for fully unattended runs. vlx init asks this question during setup (60 seconds of silence → disabled); a daemon started interactively with a config that predates the key asks once and persists the answer to vlx.yaml.
yaml
approval_gate:
enabled: false| Key | Required | Description |
|---|---|---|
enabled | No | Master switch. Defaults to false. |
fast_path — Fast-path pipeline profile
Collapse and trim the six-stage pipeline for small or docs-only stories the operator has explicitly marked with ADO tags. No LLM decides the path — the profile is a pure function of the story's tags (case/bracket-insensitive), and untagged stories are unaffected. The tags are the operator interface; fast_path.enabled is only a global kill-switch.
yaml
fast_path:
enabled: true| Key | Required | Description |
|---|---|---|
enabled | No | Master switch. Defaults to true (tag-gating active). Set false to ignore all tags. |
The three tags (independent — the skip tags work with or without vlx-small):
| Tag | Effect |
|---|---|
vlx-small | Collapse Think + Plan into one agent session (produces both spec.md and plan.md); keep the normal APPROVAL gate; keep Build; run Review as a shallow single pass (skip the /codex cross-model second opinion); keep Test. |
vlx-skip-review | Bypass the Review stage entirely. Emits a stage_skipped audit event and stamps the PR. |
vlx-skip-test | Bypass the Test stage entirely — docs-only / non-code changes ONLY (no gates run). Emits a stage_skipped audit event and stamps the PR with a "no gates ran" warning. |
APPROVAL is kept under vlx-small (governed by approval_gate); the profile never removes approval and never forces it on when globally disabled. Every skip is recorded (audit event) and disclosed (a Pipeline profile section in the PR description). Restrict vlx-skip-test to docs-only changes — a UI story (ui: true) tagged with it ships with zero gates and is only warned-and-stamped, never hard-refused.
ui_test — App under test for UI stories
When a story's spec declares ui: true (set by the Architect for anything a user sees in a browser), the Test stage starts this command in the worktree, waits for the URL to respond, has the Inspector drive real browser flows with video + screenshots per acceptance criterion (and compare against any mockups attached to the story), then kills the process tree. The evidence is posted to the story's Telegram topic and attached to the work item.
A UI story escalates fail-closed when this section is missing — it will never silently ship untested.
yaml
ui_test:
start_command: "bun run dev"
url: "http://localhost:3000/"
ready_timeout_ms: 60000| Key | Required | Description |
|---|---|---|
start_command | Yes | Shell command that starts the app (run in the story's worktree). |
ready_timeout_ms | No | How long to wait for the first HTTP response. Defaults to 60000. |
url | Yes | URL the app serves once ready — also handed to the Inspector's browser run. |
Per-project override: each entry under projects: may carry its own ui_test block; the top-level section is the fallback.
auto_merge — Automatic merge (feature-flagged)
Auto-merge is off by default. Enable only after you are comfortable with the agent's track record on a project. See Concepts — Auto-merge for the eligibility model.
yaml
auto_merge:
enabled: false
cooldown_minutes: 240
min_trust_score: 0.80
unattended: false| Key | Required | Description |
|---|---|---|
enabled | No | Master switch. Defaults to false. |
cooldown_minutes | No | Minutes to wait before merging (from the vote, or from build-status turning green in unattended mode). Defaults to 240 (4 hours). |
min_trust_score | No | Minimum trust score (0.0–1.0) the project must have for a story to be auto-merge eligible. Defaults to 0.80. |
unattended | No | Schedule auto-merge once the PR's build-validation status succeeds — no human vote or auto-merge-ok tag required. Defaults to false. No human review gate — only enable once the project's trust score has proven itself. ADO-only in this release: getPullRequestBuildStatus is not yet implemented for the GitHub adapter, so adapters.scm_host: "github" with unattended: true fails fast at daemon startup instead of silently no-op'ing. |
adapters — Swap adapter implementations
Omit this section to use all defaults.
yaml
adapters:
tracker: "ado" # ado | github | jira
human_channel: "telegram" # telegram | teams | web
scm_host: "azure_repos" # azure_repos | github
agent_session: "claude_code" # claude_code | claude_cliEach value is a type string resolved to a factory in src/bootstrap/adapter-registry.ts. Unknown types fail at startup with the slot name and known types listed.
agent_session: claude_cli — the ACP escape hatch
claude_code (the default) drives every LLM turn through the third-party claude-agent-acp ACP server. claude_cli drives the Claude Code CLI directly — claude -p (headless stream-json) for fresh turns, and claude -p --resume <session-id> for clarification resumes.
When to flip: upstream ACP breakage (a wire-format change, an abandoned repo, an npm supply issue). The flip is operational, not a migration: set the value, restart the daemon. Both transports share ~/.claude session storage, so even mid-flight stories resume across the switch, in either direction. Startup verifies the claude binary is invocable (and logs its version) when any project uses claude_cli.
Permission gating delta. Neither adapter ever bypasses permissions, and both run the same pure classifier (src/adapters/acp/permission-classifier.ts). The transport differs: ACP gates via an in-process callback; claude_cli gates via the mcp__vlx__PermissionPrompt MCP tool (--permission-prompt-tool).
| Classifier verdict | claude_code (ACP) | claude_cli |
|---|---|---|
| safe | auto-approved | auto-approved ({"behavior":"allow"}) |
| sensitive | Telegram/web approval tap | denied with a redirect: the agent is told to request operator approval via AskUserQuestion, which lands in the same Telegram thread as a normal clarification |
| missing wiring | handler throws (loud) | denied with a "misconfigured" message (loud, fail-closed) |
Nothing is ever silently allowed. The operator-visible difference: a sensitive operation under claude_cli arrives as a clarification question instead of an approval button, and answering it resumes the stage.
Degraded/unsupported vs ACP (contract-compatible — stage handlers consume none of these):
| Feature | claude_code | claude_cli |
|---|---|---|
| Streaming granularity | intra-message text deltas | whole assistant messages (coarser heartbeats/digest lines) |
| Tool display enrichment | displayTitle/toolCallId pairs | plain tool names only |
| Usage/pricing telemetry | per-turn token usage aggregated per stage session; usage.reported (executed-model-attributed) emitted on completion | unpriced (no usage surface); configured model still recorded on stage_sessions |
| Mid-turn permission taps | yes | replaced by deny-with-redirect (matrix above) |
available_commands / thoughts | skipped | not emitted — nothing to map |
The in-repo source of truth for both tables is the module header of src/adapters/claude-cli/session.ts.
stages — Per-stage model + reasoning effort
Optional. Route each LLM-backed stage to its own model at its own reasoning effort. Omit the whole block (or any single stage) to inherit the agent's default model — with no stages block the pipeline behaves exactly as before. This is static config only: there is no automatic or cost-based model selection.
yaml
stages:
think: { model: "claude-opus-4-8", reasoning_effort: "high" }
plan: { model: "claude-opus-4-8", reasoning_effort: "high" }
build: { model: "claude-opus-4-8", reasoning_effort: "high" }
review: { model: "claude-opus-4-8", reasoning_effort: "medium" }
test: { model: "claude-sonnet-5", reasoning_effort: "medium" }
reflect: { model: "claude-haiku-4-5", reasoning_effort: "low" }
respond: { model: "claude-haiku-4-5", reasoning_effort: "low" }Keys are the seven LLM-backed invocations: the six pipeline stages that run an actor turn (think, plan, build, review, test, reflect) plus respond (the out-of-band PR-comment responder). The non-LLM gates approval and ship have no key — supplying one is a hard startup error.
| Key | Required | Description |
|---|---|---|
<stage>.model | No | Model id to route this stage to (any non-empty string your account exposes). Omit to inherit the default. |
<stage>.reasoning_effort | No | low, medium, or high. Omit to inherit the default. |
Validation is strict: an unknown stage key (e.g. stages.deploy), an unknown field under a stage entry (e.g. temperature), or an invalid reasoning_effort is rejected at config load with a clear error. The recommended cost profile above routes the reasoning-heavy stages to a strong model, Test to a mid model, and the cheap classification/summarization stages (Reflect, Responder) to a cheap model.
Rolling the daemon back to a build without this feature? Remove or comment out the
stages:block first — an older binary's strict schema rejects it.
Environment variables quick-reference
| Variable | Purpose |
|---|---|
ADO_PAT | ADO Personal Access Token (Work Items + Code R/W) |
TELEGRAM_BOT_TOKEN | Telegram bot token |
TELEGRAM_GROUP_CHAT_ID | Telegram group chat ID |
GITHUB_TOKEN | GitHub token (when using GitHub adapter) |
JIRA_EMAIL | Jira account email (when using Jira adapter) |
JIRA_API_TOKEN | Jira API token (when using Jira adapter) |
TEAMS_CLIENT_SECRET | Teams app client secret (when using Teams adapter) |
VLX_HOME | Operational root (default: ~/.vlx) |
VLX_CONFIG | Path to vlx.yaml (default: ~/.vlx/vlx.yaml) |
VLX_DB_PATH | Path to state.db (default: ~/.vlx/state.db) |
VLX_UPDATE_URL | Override the self-update release-channel base URL |
VLX_NO_UPDATE | 1 skips the daemon's on-start update check |
LOG_LEVEL | Log verbosity override (overrides log.level in config) |
Secrets (ADO_PAT, TELEGRAM_*, adapter tokens) are auto-loaded from ~/.vlx/secrets/global.env at startup. Variables already set in the host environment always win over the file.