Skip to content

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 values

Set 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"
KeyRequiredDescription
org_urlYesADO organisation URL.
projectYesADO project name (case-sensitive).
pat_envYesName 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_assigneeNoEmail 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"
KeyRequiredDescription
ownerYesGitHub organisation or user owning the repository.
repoYesRepository name.
token_envYesName of the env var holding the GitHub token. Required scopes: repo (full), read:org.
bot_loginNoGitHub login stories must be assigned to. Defaults to the token owner.
default_branchNoPR 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"
KeyRequiredDescription
base_urlYesJira Cloud base URL.
project_keyYesJira project key (e.g. VLX). Stories in this project are polled.
email_envYesName of the env var holding the Atlassian account email.
api_token_envYesName of the env var holding the Atlassian API token.
bot_account_idNoPoll issues assigned to this Jira accountId. Defaults to the API token owner.
actionable_statusesNoIssue statuses the agent will pick up. Defaults to ["To Do", "Open"].
statusesNoMap 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"
KeyRequiredDescription
bot_token_envYesEnv var name holding the Telegram bot token.
group_chat_id_envYesEnv 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_idsYesArray of Telegram user IDs allowed to interact with the bot. Unauthorised updates are silently ignored.
state_fileNoPath 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"]
KeyRequiredDescription
tenant_idYesAzure AD tenant ID.
client_idYesApp registration client ID. The app needs ChannelMessage.Send, Chat.ReadWrite, TeamsAppInstallation.ReadWriteForTeam Microsoft Graph permissions.
client_secret_envYesEnv var name holding the client secret.
team_idYesID of the Teams team.
channel_idYesFull channel ID (thread format).
authorized_user_idsYesAAD 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
KeyRequiredDescription
enabledNoStart the embedded dashboard. Defaults to true (the server starts unless explicitly set to false), independent of which human_channel is chosen.
portNoTCP port bound on 127.0.0.1 only — never exposed on other interfaces. Defaults to 8787.
tokenNoSecret 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"
KeyRequiredDescription
org_urlYesADO organisation URL (same as ado.org_url typically).
projectYesADO project name.
repository_idYesRepository UUID (visible in the ADO repository settings URL).
repo_nameYesRepository name (used for display and branch operations).
pat_envYesEnv 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"
KeyRequiredDescription
worktree_rootNoRoot directory for per-run git worktrees. Relative to the CWD where the daemon runs. Defaults to .vlx/worktrees.
source_repo_pathNoPath to the source git repo the daemon manages. Defaults to . (CWD).
base_branchNoBase 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 init always writes an explicit per-project runtime block (all three keys), so entries never depend on the global fallback.
  • adapters.human_channel must be web. Telegram allows only one getUpdates poller per bot token and Teams would double-process commands, so for those channels run one daemon per project (separate VLX_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 to vlx.yaml.bak.
  • Repo already configured → vlx init prints 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"
KeyRequiredDescription
levelNoLog 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
KeyRequiredDescription
allowed_hostsNoHostnames 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_registriesNoPackage registry URLs the agent may install from without approval (e.g. ["https://registry.npmjs.org"]).
approval_timeout_msNoMilliseconds 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
KeyRequiredDescription
enabledNoMaster 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
KeyRequiredDescription
enabledNoMaster 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):

TagEffect
vlx-smallCollapse 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-reviewBypass the Review stage entirely. Emits a stage_skipped audit event and stamps the PR.
vlx-skip-testBypass 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
KeyRequiredDescription
start_commandYesShell command that starts the app (run in the story's worktree).
ready_timeout_msNoHow long to wait for the first HTTP response. Defaults to 60000.
urlYesURL 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
KeyRequiredDescription
enabledNoMaster switch. Defaults to false.
cooldown_minutesNoMinutes to wait before merging (from the vote, or from build-status turning green in unattended mode). Defaults to 240 (4 hours).
min_trust_scoreNoMinimum trust score (0.0–1.0) the project must have for a story to be auto-merge eligible. Defaults to 0.80.
unattendedNoSchedule 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_cli

Each 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 verdictclaude_code (ACP)claude_cli
safeauto-approvedauto-approved ({"behavior":"allow"})
sensitiveTelegram/web approval tapdenied 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 wiringhandler 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):

Featureclaude_codeclaude_cli
Streaming granularityintra-message text deltaswhole assistant messages (coarser heartbeats/digest lines)
Tool display enrichmentdisplayTitle/toolCallId pairsplain tool names only
Usage/pricing telemetryper-turn token usage aggregated per stage session; usage.reported (executed-model-attributed) emitted on completionunpriced (no usage surface); configured model still recorded on stage_sessions
Mid-turn permission tapsyesreplaced by deny-with-redirect (matrix above)
available_commands / thoughtsskippednot 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.

KeyRequiredDescription
<stage>.modelNoModel id to route this stage to (any non-empty string your account exposes). Omit to inherit the default.
<stage>.reasoning_effortNolow, 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 ​

VariablePurpose
ADO_PATADO Personal Access Token (Work Items + Code R/W)
TELEGRAM_BOT_TOKENTelegram bot token
TELEGRAM_GROUP_CHAT_IDTelegram group chat ID
GITHUB_TOKENGitHub token (when using GitHub adapter)
JIRA_EMAILJira account email (when using Jira adapter)
JIRA_API_TOKENJira API token (when using Jira adapter)
TEAMS_CLIENT_SECRETTeams app client secret (when using Teams adapter)
VLX_HOMEOperational root (default: ~/.vlx)
VLX_CONFIGPath to vlx.yaml (default: ~/.vlx/vlx.yaml)
VLX_DB_PATHPath to state.db (default: ~/.vlx/state.db)
VLX_UPDATE_URLOverride the self-update release-channel base URL
VLX_NO_UPDATE1 skips the daemon's on-start update check
LOG_LEVELLog 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.

Internal Veloxcore tool — not a public product.