neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
Like if Kubernetes and localhost had a magic, agentic baby — with a gorgeous UI.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Like if Kubernetes and localhost had a magic, agentic baby — with a gorgeous UI.
Real HTTPS URLs for every service you run — one command, no YAML, no cloud bill. Then you and your coding agent build on it, together.
veld is a local development environment orchestrator — for a monorepo, a pile of separate repos, or any set of services you run together. Declare them in one veld.json; veld resolves the dependency graph, starts everything, runs health checks, and hands you clean, stable HTTPS URLs.
No port numbers. No manual wiring. Just clean, stable, human-readable URLs.README 전문 보기 ↗Like if Kubernetes and localhost had a magic, agentic baby — with a gorgeous UI.
Real HTTPS URLs for every service you run — one command, no YAML, no cloud bill. Then you and your coding agent build on it, together.
veld is a local development environment orchestrator — for a monorepo, a pile of separate repos, or any set of services you run together. Declare them in one veld.json; veld resolves the dependency graph, starts everything, runs health checks, and hands you clean, stable HTTPS URLs.
veld orchestrator start frontend:local --name my-feature
# => https://frontend.my-feature.myproject.localhost
# => https://backend.my-feature.myproject.localhost
No port numbers. No manual wiring. Just clean, stable, human-readable URLs.
This thing is 100% vibe coded with Claude Code.
localhost:3847--name dev); re-running by name is idempotentveld orchestrator runs list lists it, veld orchestrator logs --run <id> and veld orchestrator logs --previous target it, and a crash is distinguishable from a clean stopfullstack, ui-only); a preset can compose others with @name. Every preset shows a key, the number you type at veld orchestrator start — pin it and it never moves again, whatever is added or renamed around it, so it stays valid in muscle memory and in a runbook (veld orchestrator presets --pin freezes the current numbering). Add an optional label, group, and when_to_use and a list of thirty becomes pickable by someone who didn't write it — and by a coding agent, which reads the same text from veld orchestrator presets --json. default_preset gives a bare veld orchestrator start a defined answer, including in a non-interactive shell${veld.port}, ${nodes.backend.url}, git branch, etc. Which built-ins exist in which context is checked by veld orchestrator lint, so ${veld.url} on a command node is refused before the run instead of failing mid-startschemaVersion: "3") — comments and trailing commas in veld.json (or name the root file veld.jsonc and your editor works it out); split the config across per-directory files with include globs so teams own their own; declare a field once at node level and override it per variant; vars for one definition point per value, interpolated and resolved only when the plan reaches them. Deduplicates values, never structure — rg <ENV_VAR> still finds the line that sets it. schemaVersion: "3" is required — an older config fails to load with an error stating every change needed. See docs/migrating-to-v3.md, written to hand to a coding agent, with veld orchestrator lint as the checkargv or shell — one vocabulary everywhere veld runs something. argv is spawned directly, so an interpolated value containing spaces or globs can never change the argument count; shell is the permanently-supported escape hatchsecret. Veld carries a pointer and a flag, never custody: a secret reaches the process's environment or a file (files:) and is refused in a command line, where it would land in the process tablemachine: { default, choices, description, prompt } and each developer answers it once with veld orchestrator vars set — stored in Veld's database, shared across every worktree of the repo rather than asked again in each one, narrowable to one checkout with --worktree. A var with no default stops the run before anything spawns, asks when there is a terminal (or a UI), and otherwise refuses with the exact command — it never resolves a default nobody chose and never persists a guesside.extensions) — a repo declares what its top bar carries: a badge for this branch's pull request, a menu that opens the worktree in WebStorm or VS Code, a coverage number. Each is backed by a command veld runs in that worktree, and veld never learns your code host's name — the command prints a tiny contract (text, tone, href, and the ids of actions to offer) and veld renders it, so a GitHub project ships a gh script and a GitLab project ships the same declaration with glab behind it. A badge whose tool is missing says so with your own install hint rather than vanishing, which is how a fresh clone teaches a new joiner what to set up. Bounded on purpose, because a badge is the only thing veld runs from a config without you clicking anything: only the worktree on screen, only while a window is open, one child process however many windows ask, no terminal attached (so a CLI that would prompt for credentials fails instead of hanging), a hard deadline, a floor on the refresh interval, a cap per project, every command logged — and one switch (Settings → General) that turns the unattended half off machine-wide"ports": { "http": "auto", "admin": { "port": "auto", "protocol": "http" }, "debug": "auto" } for debug adapters and multi-port containers, so nothing needs a hand-picked literal port that breaks parallel worktrees. Every http port gets its own hostname (web-admin.dev.veld.localhost); a tcp port is allocated and exported (VELD_PORT_DEBUG) but never routed, because a raw TCP connection carries no hostname to route on"ports": null on a long_running node: an Electron shell, a file watcher, a background compiler. No port, no URL, no route — just a process veld starts, keeps in the graph, and stops with the rest. Readiness is still required; { "type": "settle", "seconds": 3 } accepts "it was still running after 3s" when there is nothing better to probe--json for scripting and CIhttps://veld.localhost with service health, logs, search, stop/restartcommand nodes (a docker build, a pnpm install) log to their node's stream exactly like servers do, and project setup/teardown steps to the run's; nothing a step prints is thrown away. Lines also stream into veld orchestrator start's progress output as they arrive, instead of scribbling over itveld orchestrator logs --source internal. A node whose probe keeps passing writes one line an hour, not one per poll, so a quiet stream means healthycommand steps — builds, installs, codegen — are sampled too, every 2s, by the veld orchestrator start that runs them, because their processes never outlive that command and no PID for them exists anywhere else. So "what did that cargo build peak at" and "how much does the dev server hold before it serves its first request" are questions with answers. (A docker build is the exception: the work happens inside dockerd/buildkitd, outside the step's process tree, so the client is all veld can see.) The headline memory figure is the tree's footprint (proportional set size on Linux, phys_footprint on macOS), because summing RSS over a tree counts each page shared inside it once per process, so a five-process npm run dev reported far more than it occupied. On Linux the footprint splits by page class — private dirty (the heap, what grows when a node leaks), private clean, shared dirty, shared clean, swap, wired — and every node splits by subprocess, so "which child is eating the RAM" is a question with an answer. veld orchestrator stats shows it in the terminal (--processes, --history, --cpu, --memory <metric>); the dashboard expands any node into a scrubbable chart you can flip between memory and CPU, and between total, by-type and by-process. Where a bucket averages several samples, the chart plots the peak alongside the mean — a mean over a six-minute bucket hides the spike a 5s sample caught. Totals are kept 24h, per-process detail 2h (both reported by the API, so nothing hardcodes them); VELD_STATS_MEMORY_DETAIL=off and VELD_STATS_CMDLINE=off turn off the detailed probe and argv capture respectively — and because there are two samplers, each has to be set twice: in the daemon's environment (launchctl setenv / systemctl --user set-environment, then restart it; a shell export does not reach a launchd/systemd service) for long-running services, and exported in the shell you run veld orchestrator start from for build/install steps, which the CLI samples. Verify against a command node, since a server node reads only the daemon's half — see veld agent skills outputsproxy config block (project/node/variant). Veld does no header manipulation by default.veld network share); they open the same URLs on their own machine, and a shared tcp port (a database, a debugger) shows up as a local port on theirs. Consent is per port, declared in config, so a node can expose its app and withhold its ops console. Relays are configurable (public or self-hosted) for compliance. No accounts, no Veld-hosted server.veld network share --web): a self-hosted gateway (veld-gateway, one Docker container) mints a real public URL anyone can open in a browser.path:12-18 reference with the excerpt to paste back. An agent puts a file there with veld ide open plan.md --notify, and is told it can when it starts in a Veld terminal. A Changed files list is one click from a new pane⌃Tab/⌃⇧Tab steps tabs, and ⌥Tab/⌥⇧Tab steps worktrees on macOS (Ctrl+Shift+B/Ctrl+Shift+N on Linux and Windows, where Alt+Tab belongs to the window switcher). Shift is the reverse in every case, and the chords keep working while a terminal or an embedded browser pane has the keyboard. Every arrow chord is deliberately left alone — ⌘←/⌘→ is the caret motion Claude Code and Codex expect from a terminal. Tab cycling runs the worktree's whole strip, continuing into the windows you have detached panes into and raising one when it gets there. ⌘⇧D opens to the side: it moves the active tab to the other half of the dock, or — when it is the only tab there — opens a new pane on that side instead, so the chord always gives you a split. In Veld Desktop ⌘T/⌘W open and close a tab and ⌘⇧W closes the window, as they do in a browser. ⌘⇧J is the one chord that does not step a list: it jumps straight to wherever a coding agent is waiting for you — or something finished or failed — in any project — the keyboard half of the Next unread button described below. Like the rest of the navigation family it keeps working while a terminal or an embedded browser pane has the keyboard. The full list, with each platform's own modifier keys, is ⌘/ — or the ⋯ menu → Shortcuts.Download the latest release for your platform:
curl -fsSL https://veld.oss.life.li/get | bash
This detects your OS and architecture, downloads the latest release, and installs:
veld to ~/.local/bin/veld-helper and veld-daemon to ~/.local/lib/veld/In privileged mode the helper is then served from a root-owned directory
(/var/db/veld-helper on macOS, /var/lib/veld-helper on Linux) instead: it runs
as root, so a binary the installing user could overwrite would be root on the next
reboot. Updates still need no sudo — the installer hands the new binary to the
running root helper, which verifies its signature and refuses anything older than
the newer of what is running and what is already installed.
That signature is checked against a list of org keys compiled into the helper, so the key can be replaced without asking anyone to run anything — two releases, and the old key keeps working until the second one. If you hold the key, the procedure is docs/signing-key-rotation.md, and it is two releases, one new GitHub secret and one row of Rust.
No sudo required. Ensure ~/.local/bin is on your PATH.
Setup is optional — commands auto-bootstrap on first use with HTTPS on port 18443. For the full experience with clean URLs (no port numbers), run the one-time privileged setup:
veld self setup privileged
This registers system services and binds ports 80/443, so your URLs are just
https://frontend.my-feature.myproject.localhost — no :18443 suffix. Requires
sudo once; you won't be asked again.
Alternatively, veld self setup unprivileged does a no-sudo setup with HTTPS on port 18443.
The two modes differ in three ways, all of them about what needs root. Unprivileged mode uses port 18443 in URLs; it only supports .localhost domains (RFC 6761), so custom apex domains (e.g. {service}.mycompany.dev) require veld self setup privileged since they need /etc/hosts or dnsmasq management; and on macOS the keep-awake button covers a closed lid on mains power only — holding a MacBook awake with the lid shut on battery is pmset territory, which needs root (see the keep-awake paragraphs under Management UI). Everything else is identical. Linux is unaffected by that last one: its sleep inhibitor covers battery without privileges.
To install a specific version: VELD_VERSION=1.0.0 curl -fsSL https://veld.oss.life.li/get | bash
In containers or CI images without a working launchd/systemd, veld self setup fails
service registration on purpose (an unmanaged helper dies permanently on the next
update). Set VELD_ALLOW_UNMANAGED_HELPER=1 to let setup direct-spawn the helper
anyway — it will not survive reboots or binary updates.
git clone https://github.com/prosperity-solutions/veld.git
cd veld
cargo build --release
# Binaries: target/release/veld, target/release/veld-helper, target/release/veld-daemon
veld.json (or veld.jsonc — both are read) in your project root:{
"$schema": "https://veld.oss.life.li/schema/v3/veld.schema.json",
"schemaVersion": "3",
"name": "myproject",
"url_template": "{service}.{run}.{project}.localhost",
"nodes": {
"backend": {
"default_variant": "local",
"variants": {
"local": {
"type": "long_running",
"argv": ["npm", "run", "dev", "--", "--port", "${veld.port}"],
"probes": { "readiness": { "type": "http", "path": "/health", "timeout_seconds": 30 } }
}
}
},
"frontend": {
"default_variant": "local",
"variants": {
"local": {
"type": "long_running",
"argv": ["npm", "run", "dev", "--", "--port", "${veld.port}"],
"probes": { "readiness": { "type": "http", "path": "/", "timeout_seconds": 30 } },
"depends_on": { "backend": "local" },
"env": { "NEXT_PUBLIC_API_URL": "${nodes.backend.url}" }
}
}
}
}
}
veld orchestrator start frontend:local --name dev
Veld resolves the dependency graph (backend first, then frontend), allocates ports, starts processes, runs health checks, configures Caddy routes, and gives you HTTPS URLs.
veld orchestrator status --name dev
veld orchestrator urls --name dev
veld orchestrator stop --name dev
Every command is veld <part> <verb>. There are five parts: orchestrator (alias orc) runs environments, ide drives the Veld window, agent is for coding agents, network shares environments, and self looks after veld itself. Bare veld prints an overview of what is up and running, veld --help lists the parts, and veld --help-all lists every command. A retired name (the old top-level start, say) prints that it moved, for example: that is veld orchestrator start now. It exits 2.
| Command | Description |
|---|---|
veld orchestrator start [NODE:VARIANT...] --name <n> | Start an environment. With nothing to start: prompts (enter takes default_preset), or without a TTY uses default_preset directly. Returns once the environment is up (the daemon's engine owns the run, not your terminal); --attach from a terminal then follows its logs until Ctrl+C, which stops it. Ctrl+C while it is still starting cancels and cleans up (exit 130). Starts the daemon if it is not running |
veld orchestrator start --preset <NAME-OR-KEY> | Start a preset by name or by the number veld orchestrator presets shows |
veld orchestrator start <NODE:VARIANT> --oneshot [--all-logs] | Run a command node as a one-off: start its dependencies, run it to completion (streaming its output), tear everything down in reverse order, and exit with its exit code. Ideal for end-to-end test runs. |
veld orchestrator stop [--name <n>] [--all] | Stop a running environment |
veld orchestrator restart [--name <n>] | Restart an environment |
veld orchestrator status [--name <n>] [--json] | Show environment status: current run id, what the run was started from (the preset, flagged if that preset has been edited since), per-node CPU/memory footprint, and (when the environment isn't live) the last run's outcome. URLs are shown only while live |
veld orchestrator stats [--name <n>] [--node <n>] [--processes] [--history] [--cpu] [--window <d>] [--memory <m>] [--all-metrics] [--json] | Detailed resource usage. --processes breaks a node down by subprocess (pid, CPU, memory, cumulative CPU time); --history draws a sparkline over --window (30s/15m/2h), of memory or — with --cpu — of CPU; --memory picks which figure to show — footprint (default), resident, private_dirty, private_clean, shared_dirty, shared_clean, swap, wired, virtual; --all-metrics shows a column per available metric |
veld orchestrator urls [--name <n>] [--json] | Show URLs for a running environment; errors if the environment is stopped (routes are torn down with the run) |
A node's type describes its lifecycle only — whether it runs to completion or stays running. Whether it serves anything is a property of its ports.
long_running — a process veld supervises for the life of the run. By default veld allocates one port (${veld.port}), starts the process, and gates the graph on a readiness probe, which is mandatory. Declare "ports": null for a long-running process that serves nothing — an Electron shell, a file watcher, a background compiler — and it gets no port, no URL and no route. (start_server is the historical spelling and remains a permanent alias, exactly as bash is for command. Configs written either way load forever, and nothing nags you about the old one: a permanent alias sets no deadline, so there is deliberately no lint rule for it.)command — runs a command to completion. Can emit outputs by writing key=value lines to $VELD_OUTPUT_FILE (preferred) or via VELD_OUTPUT key=value on stdout (legacy, discouraged). Optional skip_if command for idempotency.ports has three authorings:
// absent → one auto-allocated http port. The default, unchanged.
// null → no ports at all: no allocation, no ${veld.port}, no URL, no route.
"ports": null
// a map → named ports, shorthand or long form. `"name": null` erases one entry.
"ports": {
"http": "auto", // primary → protocol "http"
"admin": { "port": "auto", "protocol": "http" }, // its own hostname
"postgres": { "port": 5432, "protocol": "tcp" }, // allocated, exported, never routed
"debug": "auto" // secondary → protocol "tcp"
}
http for the primary port and tcp for every other, so an existing multi-port config gains no new hostname the first time it runs on a newer veld.http port gets a hostname and a Caddy route. The primary's {service} is the node name; a secondary's is <node>-<port> — web-admin.dev.veld.localhost, a sibling of the node's own hostname at the same depth, so a wildcard cert that already covers the node covers its extra ports too. Per-port "host": "<template>" overrides the template, which is the way out of a collision.tcp port is allocated and exported as ${veld.ports.<name>} / VELD_PORT_<NAME>, and deliberately not routed: a raw TCP connection carries no hostname for a proxy to demultiplex on, and every *.veld.localhost name already resolves to 127.0.0.1 anyway, so the port number is the whole address.${veld.port} is the primary — the port named http, the sole entry marked "protocol": "http", or the sole entry when it states no protocol. Several ports with none of those is ambiguous-primary-port, a veld orchestrator lint error rather than a guess.19899 is veld's own daemon port and is never given to a node — auto-allocation skips it, and naming it explicitly is refused rather than substituted. A node that bound it would take the port from the daemon's next start, which fails later and elsewhere with nothing pointing back at the config.share opt-in — see Sharing. Consent is per port, because a node that serves an app, an ops console and a database must be able to expose one of them without exposing all three.All of this is additive within schemaVersion: "3" — there is no v4 and nothing to rewrite. docs/adopting-long-running-and-ports.md is the page to hand a coding agent if you want to adopt it, and it ends with the behaviour changes that can move under a config you never touch.
Project-level lifecycle steps that run outside the dependency graph. Setup steps run sequentially before any node starts; teardown steps run after all nodes stop.
{
"setup": [
{ "name": "docker", "argv": ["docker", "info"], "failureMessage": "Docker must be running" },
{ "name": "veld-network", "shell": "docker network create ${veld.name}-net 2>/dev/null || true" }
],
"teardown": [
{ "name": "veld-network", "shell": "docker network rm ${veld.name}-net 2>/dev/null || true" }
]
}
Setup steps that fail (non-zero exit) abort startup with the failureMessage if provided. Teardown is best-effort — failures are logged but don't block stop, and a step that hangs is killed after 30 seconds so it can't either. Commands support shell env vars, project-level Veld variables (${veld.name}, ${veld.project}, ${veld.root}, ${veld.run}, ${veld.worktree}, ${veld.branch}, ${veld.username}) and ${vars.*}. Node-scoped built-ins (${veld.port}, ${veld.url}, ${veld.node}) and ${veld.run_id} are not available — a project step belongs to no node, and a teardown can outlive the run row.
{ "type": "http", "path": "/health", "expect_status": 200, "timeout_seconds": 30 }
{ "type": "port", "timeout_seconds": 10 }
{ "type": "port", "port": "admin" }
{ "type": "command", "argv": ["curl", "-sf", "http://localhost:${veld.port}/ready"] }
{ "type": "settle", "seconds": 3 }
http and port probe the primary port unless "port": "<name>" names another. On a node with no such port they are a probe-needs-port lint error and fail the run rather than reporting healthy — an unknown type likewise (unknown-probe-type), because a typo must never be the quiet way to turn a probe off.
settle is the readiness probe for a portless long_running node. Its claim is deliberately weak and it says so: the process was still running seconds after it was spawned (default 3). That is worth having anyway, because it is raced against process exit exactly as the port probe is, so a node whose command dies on startup still fails the run instead of letting its dependents start behind a corpse. Prefer { "type": "command", … } whenever the process publishes something observable — a socket, a built file, a pid file. settle is readiness only; as a liveness check it would report healthy forever, so veld rejects it there.
| Variable | Description |
|---|---|
{service} | Node name — for a node's secondary http port, <node>-<port> |
{run} | Run name |
{project} | Project name from veld.json |
{branch} | Current git branch (slugified) |
{worktree} | Worktree directory name (slugified) |
{username} | OS username |
{hostname} | Machine hostname |
Fallback operator: {branch ?? run} uses the first non-empty value.
Add or strip HTTP headers as requests and responses pass through the reverse proxy with proxy at the project, node, or variant level (most specific wins). Rules apply to both the local Caddy proxy (local dev) and the public web gateway (veld network share --web). They do not apply to direct iroh peer sharing (veld network share without --web) — that path is a transport-level byte splice with no HTTP layer, so header rules cannot be applied there.
"proxy": {
"request": { "remove": ["Origin"] },
"response": { "set": { "X-Frame-Options": "DENY" } }
}
request rules apply to the request forwarded upstream; response rules to the response returned to the browser.remove strips the listed headers; set sets each header to the given value (replacing any existing value). Header names are matched case-insensitively.remove lists are unioned (case-insensitive) and set maps are merged per key (most specific level wins). Absent proxy means no manipulation — the default.Default behavior changed. Veld no longer manipulates any headers by default. Previously it stripped the Origin header so dev-server WebSocket HMR (Next.js, etc.) worked; now Origin passes through the local proxy, and the gateway rewrites Origin coherently to the origin host on all requests (including WebSocket upgrades) rather than dropping it. If your dev server gates WS HMR on Origin and rejects the passed-through value, set allowedDevOrigins in next.config.js (the recommended fix). For frameworks with no allow-list, use the escape hatch of stripping Origin at the proxy:
"proxy": { "request": { "remove": ["Origin"] } }
Declare env at the project, node, or variant level. Variables cascade: variant > node > project (per-key merge, most specific wins). Values support ${...} variable substitution.
Nodes do not inherit your terminal's environment. The daemon's engine spawns every node, so its base environment is the daemon's own plus your login-shell PATH (resolved in the project directory). To use a value from the shell that ran veld orchestrator start, name it with { "env": "NAME" } — Veld sends the values of exactly those names with the request and stores nothing. A run started without a terminal (the IDE, automatic recovery) has none of them and stops naming the missing variable. veld orchestrator action is the exception: it runs in your terminal with your full environment.
Veld also injects a VELD_* environment on every surface of a node — the node's process, its readiness and liveness probes, its on_stop hook, and veld orchestrator action: VELD_RUN, VELD_RUN_ID, VELD_ROOT, VELD_PROJECT, VELD_NODE, VELD_VARIANT, plus VELD_PORT/VELD_URL/VELD_PORT_<NAME>/VELD_URL_<NAME>/VELD_HOST_<NAME> on a long_running node that has ports. A command probe also gets the node's declared env and outputs as $KEY, and so does an action on the node. Probe fields (path, argv/shell) are interpolated like the node's own argv/env. See Environment variables in the config reference.
{
"env": { "FEATURE_FLAG": "1" },
"nodes": {
"api": {
"env": { "LOG_LEVEL": "debug" },
"variants": {
"local": {
"env": { "PORT": "${veld.port}" }
}
}
}
}
}
Commands, env values, and output templates support ${veld.port}, ${veld.url}, ${veld.run}, ${veld.root}, ${nodes.backend.url}, ${nodes.backend.port}, etc.
For long_running nodes with an http port, individual URL location pieces are also available (mirrors the Web URL API):
| Variable | Example | Description |
|---|---|---|
${veld.url.hostname} | app.my-run.proj.localhost | DNS name only |
${veld.url.host} | app.my-run.proj.localhost:19443 | hostname:port (omits port if 443) |
${veld.url.origin} | https://app.my-run.proj.localhost:19443 | scheme + host (same as ${veld.url}) |
${veld.url.scheme} | https | Protocol scheme |
${veld.url.port} | 19443 | HTTPS port (note: ${veld.port} is the backend bind port) |
These are also available as cross-node references: ${nodes.backend.url.hostname}, ${nodes.backend.url.host}, etc., and in the node's own on_stop hook — so a container named after ${veld.url.hostname} in argv is removed by the identical string at teardown.
${veld.url} is the primary port's URL. Every other http port has the same family under its own name — ${veld.urls.<name>} plus .hostname, .host, .origin, .scheme, .port, cross-node as ${nodes.<node>.urls.<name>.origin}, and in the environment as VELD_URL_<NAME>. A tcp port has a ${veld.ports.<name>} but no URL, and veld orchestrator lint says so by name rather than reporting an unknown built-in.
Ports and URLs for all long_running nodes are pre-computed before execution, so ${nodes.X.url} works everywhere — even across nodes with no dependency relationship. Frontend can reference backend's URL and backend can reference frontend's URL without a cycle.
Availability is not uniform — a command node has no port or URL of its own, nor does a long_running node that declared "ports": null, a project setup step belongs to no node, and a vars value is one value for the whole run. veld orchestrator lint reports a real built-in written where it is not populated (builtin-not-in-scope) rather than letting the run fail with unknown built-in variable. See Availability.
Three binaries work together:
veld — CLI, a thin client of the engine in the daemon: it sends start/stop/restart/status/logs/action requests, draws the progress it streams back, and starts the daemon if it is not running.veld-helper — manages DNS entries and Caddy routes via a minimal Unix socket API. Runs as either a system daemon (privileged, for clean URLs on ports 80/443) or a user process (unprivileged, on port 18443).veld-daemon — user-space daemon. Hosts the engine (veld-engine, which orchestrates environments): every start, stop, restart and action — from the CLI or the IDE — runs in a short-lived worker process of its own, so it survives a daemon restart and veld self update. Started by launchd/systemd after veld self setup; when none is installed (CI, a fresh machine), the first veld command starts one itself, and veld self update and veld self uninstall stop that one too. Also monitors health, runs garbage collection, broadcasts state updates.Caddy handles HTTPS termination and reverse proxying. Its internal CA is trusted in the system keychain during setup so browsers accept certificates without warnings.
Certificates are issued by that CA for a week at a time (under an intermediate good for 30 days), and Caddy renews them on its own once a third of that is left. On a machine that already had veld, the existing intermediate keeps its old 7-day life until Caddy rotates it, and a leaf is never allowed to outlive its issuer — so for the first few days after this update, certificates are shorter than a week (whatever is left of the old intermediate) rather than immediately seven days. Nothing to do; the watchdog below measures each certificate against its own lifetime, not against a fixed number. Veld does not take that on trust: every minute the helper completes a TLS handshake on the HTTPS port and reads the validity dates off the certificate it is handed. If Caddy is serving one that has expired — or is so close to expiring that its renewal window has long passed — the helper restarts Caddy, which is what actually renews it: Caddy will not re-examine a certificate already in its cache, so reloading its configuration (which veld does on every route change) cannot. veld self doctor asks the same question and prints the answer as Certificate:, next to the CA: row that only ever said whether the authority was trusted.
The reason both exist: a Caddy whose certificate maintenance had stopped once served an expired certificate for a day and a half. Every check veld had stayed green — the admin API answered, the CA was trusted — while Chrome refused every veld URL with ERR_CERT_DATE_INVALID.
On macOS a launchd job's stdout and stderr are discarded unless its plist names a file, so:
~/.veld/veld-daemon.log, owner-only. In the user's own directory rather than beside the binary, because the daemon is a user LaunchAgent and a legacy /usr/local install's lib dir is root-owned — and launchd does not run a job whose log file it cannot create, it exits it EX_CONFIG before the program starts./var/db/veld-helper/veld-helper.log, since that is the root-owned directory it is served from (<lib dir>/veld-helper.log on an install still awaiting that move, and on a legacy /usr/local one). It runs as root, so the directory is writable whichever it is, and the file is world-readable so you do not need sudo to read it. On Linux there is no such file: the helper is a systemd service and its output goes to the journal — journalctl -u veld-helper. The unprivileged helper has no log file on either platform.<lib dir>/caddy-data/caddy.log, rolling at 4 MB and keeping two rolls. Not via a plist — the helper spawns Caddy itself, with its output discarded — so Caddy is configured to write the file. Inside caddy-data rather than beside the other logs on purpose: in privileged mode Caddy runs as root while the lib dir is user-owned, and Caddy opens its log without O_NOFOLLOW, so a symlink planted at a user-writable path would have root appending wherever it pointed. caddy-data is root-owned. The helper creates the file itself, 0644, so you can read it without sudo, and if it cannot be made openable veld leaves the log out of Caddy's config rather than let a log cost you every route (naming an unopenable log fails the whole config). It matters more than it looks: certificate issuance and renewal happen entirely inside Caddy, veld cannot report on them, and a renewal that fails is written down here and nowhere else. No access logs go here, only lifecycle and errors.On Linux systemd captures unit output itself: journalctl --user -u veld-daemon. veld self doctor prints the daemon's log location in its Installation block — read out of the service definition, so it names the file that is really being written — and says plainly when a machine is not capturing it, which is the case for any install set up before this existed. veld self setup <mode> writes the current definition.
All CLI/daemon state — run state, the project registry, service logs, per-node and per-process resource samples, relay auth tokens, the desktop worktree registry, and the IDE's pane layouts — lives in one SQLite database at <data_dir>/veld/veld.db (macOS: ~/Library/Application Support/veld/veld.db; Linux: ~/.local/share/veld/veld.db; override with VELD_DB_PATH). The file is 0600 (it holds secrets) and runs in WAL mode, so the CLI, daemon, and detached log writers read and write concurrently without file locking. The schema is versioned (PRAGMA user_version) and migrates forward automatically on upgrade — a CLI update never orphans or stops running environments because the data shape changed. A database created by a newer veld is refused with an error instead of being modified.
One thing deliberately lives outside it: files handed to a terminal pane. Dropping a file into a pane, or pasting a screenshot, needs a real path on disk for the program in that pane to read, and a path is not something a database row can be. In the desktop app a dropped file's own path is used and nothing is copied; a pasted image — and any drop from a plain browser tab, where the File API withholds the path — is written to ~/.veld/pastes (0700, files 0600 at creation, overridable with VELD_PASTE_DIR). Copies are deleted after a day, by the periodic garbage collection and on the next paste, and veld self uninstall removes the directory with the rest of ~/.veld.
Because all of that is one file, the daemon keeps copies of it. Every
backup.intervalMinutes (60 by default) it writes a compact copy into
<data_dir>/veld-backups/ — a sibling of the data directory rather than a
child, so an rm -rf of Veld's own folder does not take the copies with it. Point
backup.dir at an external drive or a synced folder if you want them off this
disk; no local default survives the disk itself failing.
A copy is a real veld.db, so restoring is a file move and not an import. It omits
the tables that make the live file large — log lines and resource samples,
both of which the housekeeping pass already prunes on an age horizon —
which is why a copy is typically a few megabytes against a live file that can reach
hundreds. Retention keeps backup.keep recent copies plus one per day for
backup.keepDaily days. It only ever deletes files Veld wrote itself and never
touches the folder's own permissions, so the folder is safe to share — and it
never deletes a copy it cannot read, because a backup with a damaged header may
still be recoverable and is exactly the file worth keeping.
veld self backup list # what is there, and which copies are actually usable
veld self backup now # take one right now
veld self backup restore # put the newest restorable one back
veld self settings list backup # the five settings, with their bounds
veld self doctor reports the newest usable copy and its age alongside its database
check, and names veld self backup restore when the database will not open — which is
the state the whole thing exists for. The database being replaced is renamed, never
deleted: it is the only evidence of what went wrong.
A damaged SQLite file does not announce itself. Most of it keeps working — reads
and writes that never touch the damaged page succeed — so the failure mode is not
a crash but silence, with backups quietly stopping because the thing being
copied cannot be read. That is the shape of a real incident this now covers: one
page of one table went bad, the daemon logged the same error 440 times over 17
hours, no copy was written in all that time, and the only visible symptom was one
project labelled unavailable in the IDE.
So Veld watches its own file rather than waiting to trip over it:
quick_check against the live
database when it starts and then hourly — every five minutes while a fault is
recorded, so a cleared fault is noticed promptly — so damage is found in a table
nothing happens to be reading. The check reads every page, so it costs
milliseconds on a small database and seconds on a multi-gigabyte one. Errors that other parts of the daemon hit are folded in
too, so the first symptom of a fault you do touch is recorded immediately.veld self backup now that succeeds means you are protected, and the reason the
schedule failed is still on the details screen. veld self backup list prints a warning
above the table when nothing has been written for two intervals, and says it in
--json too.veld self gc each decide this for themselves, so an
automatic GC cannot undo it. Running environments keep writing their logs, so
the database may grow until you restore it; the trade is deliberate, because the
alternative is deciding to delete a checkout from a row read out of a file
SQLite has called malformed.veld self backup restore.veld self doctor's database row asks the same question the daemon does. It used to
report Database OK for any file that opened, which a damaged one does — that
row now runs the integrity check and says Database DAMAGED with what SQLite
said.
A repository being unavailable and Veld's database being damaged are now
different things, said in different places. The repository unavailable label
means what it says — the checkout is gone from disk, or git cannot read it — and
never that a write failed.
Two things worth knowing about the copies themselves. Each carries everything the
database does, including your relay tokens — Veld writes one readable only by
you, which a FAT-formatted drive or a network share cannot honour, and says so when
that happens. And veld self uninstall removes the default backup folder for that same
reason; a backup.dir you pointed somewhere else is your folder and is left alone.
Veld includes a browser-based dashboard at https://veld.localhost (or https://veld.localhost:18443 in unprivileged mode). It shows all environments with:
crashed/failed in red) and a one-click Restart. The history horizon (Settings → General) keeps that list short: it defaults to the last 3 days, and anything older is hidden from History and from the past-run pickers — with a line saying how many, because a list that quietly omits things is indistinguishable from data loss. Clear the field to show everything. Nothing is deleted by it, and nothing is kept longer either: housekeeping already removes ended runs after 7 days, which is why 7 is the maximumdirect, or relayed via <relay> — throughput capped by the relay) so slow shares are diagnosable at a glance. The two kinds of share say which they are: Share privately is the direct peer tunnel (they need Veld), Share to the web is the public URL (they need a browser). A public web share is laid out as a row per service — the URL, Copy link, Copy link + QR, Copy QR, and a QR code shown right there for opening it on a phone without retyping. The code encodes the same link the copy button gives you, password fragment included, so treat it like the password it carries. Copy link + QR puts both the text and a picture of the code on the clipboard, so pasting into Slack gives your reader something to point a phone at (which flavour a chat app takes is its own choice — the text is still on the clipboard), and Copy QR is the image alone, for sending the code after the linkOpen it with veld ide dashboard or visit the URL directly.
An experimental second-generation management UI is served at /ide (worktree mode: import git repositories, manage git worktree checkouts, and drive veld runs per worktree). It is also the web core of Veld Desktop, an Electron shell in desktop/ — see desktop/ARCHITECTURE.md.
Opened with no projects, /ide is a start screen rather than an empty page: the Veld wordmark, three things Veld is for, and a single Import your first project button (which the top bar carries too, in place of the project selector there is nothing yet to select between). Once you have a project, Veld occasionally shows a short What's new card — only for changes that alter how you work, never for fixes or flags. Closing one with Got it marks it read; dismissing it with Esc stops it reappearing but keeps it on the count, so clearing a dialog mid-task doesn't lose it. The ⋯ menu at the end of the top bar carries a dot and an unread count, and What's new… there reopens everything any time. News that shipped before your first session is never put in front of you — updating into a new version doesn't dump a back-catalogue on you — but it is still there to read. See docs/promotions.md.
Working several projects at once has its own column. Turn it on with ⌘B, or the stacked-layers button in the top bar (left of the project selector, ui.showProjectColumn, off by default — most installs have one project and a column of one square answers nothing) and every project gets a square down the left edge, beside the worktree rail. ⌘1…⌘9 go straight to one, ⌘` flips back to the one you came from, and squares are dragged to reorder — that order is the daemon's, so it is the same in every window, which is what makes ⌘2 mean one project rather than two. A square is the project's initials — worked out across the whole column, so a family sharing a prefix (SE-azure-cdn, SE-azure-identity) gets CD and ID rather than two identical squares — greyscale, so it never competes with the worktree markers beside it, and there is no marker to pick. Right-click one for switch / open-in-a-new-window / remove, and the + at the bottom imports another.
Going back to a project puts you back on the checkout you left it on. Every way of switching — a square, ⌘1…⌘9, ⌘`, the project menu, ⌘K — reopens that project's last worktree rather than its main checkout, and the memory outlives a restart. Each Veld Desktop window keeps its own places, so two windows on one project don't drag each other around; a window that has never been on that project starts where any window last left it, and a project nothing has ever opened starts on the main checkout as before. (Browser tabs have no window identity to key on, so tabs against one daemon share one memory.) What is remembered is where a window actually ended up: if the remembered checkout is already open somewhere else, the usual hunt lands you on a free one instead of pulling it away, and that becomes the place.
The column exists because of the thing a menu cannot do: each square carries the same activity glyph the rail puts on a worktree — an ear when something is waiting for you, a triangle when a command failed, a tick when something finished — aggregated over that project's checkouts, worst-state-first, and it is on screen without a click. A second, quieter dot in the corner means some other window or browser tab already has one of that project's checkouts.
The project menu carries every project action, and is the whole surface when the column is off. First in the top bar: Import repository…, then one row per project, each opening a submenu with Open, Open in a new window (Open in a new tab in a plain browser — a real second view, since a tab takes part in ownership exactly like a window), Move up / Move down, and Remove project…. Move up/down are the keyboard's half of the column's drag and write the same order, so either one moves ⌘1…⌘9 with it. Each row shows its own activity glyph and either where the project already is — open in Veld Desktop, open in Chrome — or the ⌘N that reaches it; the away note is a warning rather than a reason to grey the row out, because picking one of that project's held checkouts is exactly what brings the other window forward. A dot on the selector button says one of the projects you are not looking at has news; it ignores running, since something merely in flight somewhere would light it more or less permanently. Going to a specific checkout in another project is ⌘K's job: it lists every project's worktrees now, not just the selected project's.
⌘1…⌘9 are Veld Desktop only — Chrome and Safari reserve them for their own tabs and a page cannot intercept them. ⌘` is best-effort in both: macOS and most browsers bind it to "cycle windows in this app", and where they do, that wins.
The rail down the left side is the list of checkouts in the selected project, grouped into groups you name. Each group has its own "+", so a new worktree is created straight into the section you want it in rather than created and then dragged. Name it whatever you like — "Hello test", "Checkout V2 (final)" — or name it nothing at all and let the dialog take one from your prompt; either way that is what the rail shows; an identifier (hello-test) is derived underneath for the branch and the run name, and the dialog shows you both before it creates anything. Either can be changed later from the row's ⋮ menu, and clearing the name goes back to showing the identifier.
It has terminal panes: real shells in the selected worktree's directory, in a dock of two tab strips you can split, reorder and drag tabs between. Dragging a tab onto a pane's left or right edge splits there, and dropping it anywhere else in a pane moves it into that pane — the same gesture, with the target read off where you let go. A tab strip is one keyboard stop: ←/→ move between its tabs (Home/End to the ends) without switching panes as they go, Enter switches to the focused tab, and Delete closes it. The shell runs in the daemon and reaches the browser over a WebSocket, so terminals work in a plain browser and not only in the Electron app.
Sessions outlive the page. Reloading (or Electron reloading its window) reattaches to the same shells with their scrollback intact, and output produced while you were away is replayed — a build keeps running and keeps logging. Shift+Enter inserts a newline instead of submitting, which is what Claude Code and other coding agents read as a line break in their input box.
A pane behaves like the terminal you came from. On macOS, ⌘←/⌘→ move to the start and end of the line and ⌘⌫ deletes it, the same substitution Ghostty and iTerm2 make (⌥+arrows already move by word).
Drop files onto a pane, or ⌘V a screenshot. Files arrive as their absolute paths, shell-escaped, several files giving several paths — which is how you point an agent at something without going to look up where it lives. An image arrives as an image: a coding agent shows it as an attachment ([Image #1] in Claude Code), not as a path you then have to hope it reads. Screenshot, ⌘V, ask about it.
A pty carries bytes rather than pictures, so no terminal can send an image down the stream — what actually travels is still a path. What makes it an attachment rather than text is that veld sends it as a paste rather than as typing, which is the signal agents use to tell "a file I should look at" from "some characters". The same distinction is why a dropped source file stays text: there is no image there to attach. This is the route iTerm2 and cmux take too, and it needs nothing installed and nothing on your clipboard. It does need the program to have turned bracketed paste on, which is how it can tell a paste from typing — every coding agent does, but that is a property of the agent rather than something veld can guarantee.
They also outlive the daemon. veld self update restarts it, and a daemon can crash; neither ends your shells. Each terminal's pseudo-terminal is owned by a small holder process of its own rather than by the daemon, so the new daemon finds the running shells and picks them back up with their scrollback — an update is no longer something to schedule around, and an agent working in a pane keeps working through one. Quitting Veld Desktop and reopening it restores your panes too, so the tabs come back attached to the same shells. A reboot is the one thing nothing survives.
A dropped pipe is usually the same story: the machine slept or a proxy timed out, and the shell is still running. So when the connection to a terminal breaks, Veld reconnects on its own a few times (near-immediately first, then with a backoff — Settings → Terminal → Auto-reconnect) before it settles and offers the Reconnect button, so a transient blip never interrupts a build in front of you.
Each open terminal has a small holder process of its own, and its socket lives in ~/.veld/pty-<daemon port>/ — veld self doctor reports how many there are, which is where to look if a terminal did not come back. It only knocks on them when the daemon is down, and says so in the row: connecting to a holder is how a daemon arrives, so a diagnostic that knocks while your terminals are attached is a diagnostic that can cost you one. With no daemon there is nothing to disturb, and that is also the moment the answer matters — a socket nobody answers is a holder that is gone, and the next daemon start sweeps it. If that directory's path is too long for a unix socket (104 bytes — a deep $HOME can reach it), veld self doctor fails that check and names VELD_PTY_DIR as the way out, rather than leaving you with a terminal that will not open.
Closing a terminal pane is what ends a shell; closing the browser window or quitting the app only detaches, and a session no window has a pane for any more is hung up after 30 minutes by default (Settings → Terminal) — as is one whose daemon never returns, so nothing is left running for a daemon that is gone for good. A dropped connection does not start that clock. A sleeping laptop, a veld self update and a terminal that has given up reconnecting all detach a session while Veld is still open in front of it, and the shell is kept for as long as some window or tab still has the pane — so a build survives a closed lid and reattaches when you come back, rather than being collected half an hour into the nap. The clock starts when the last window holding it goes. If a foreground process is still running in a terminal, closing it asks first — the same "are you sure, that process will be terminated" a real terminal asks — so an accidental X on a tab running a build or an editor doesn't lose it. Opening the same terminal in a second window takes it over rather than mirroring it, so there is only ever one writer. Up to 48 sessions can be live at once by default (Maximum open terminals, Settings → Terminal, up to 128); past that, opening a terminal reports the limit and closing a pane frees a slot. Selecting a worktree no longer opens a terminal by itself: its pane offers the project's panes, a plain shell and the run's URLs, so browsing the rail costs nothing and a shell starts when you ask for one.
A URL in the terminal opens in a pane. Addresses in the output are links: click one and it opens as a browser tab in the same dock, next to the shell that printed it. The same goes for a program in the terminal that wants to open a browser — Veld points $BROWSER at itself, which is what Claude Code, gh, git, vite and next all consult, so an agent's login link or a dev server's --open lands in a pane instead of pulling you out of the app. (veld ide open-url <URL> is the same thing to invoke by hand, or from a script.)
And a file path in the terminal opens in Veld, or in your editor. src/api.ts, or a compiler's crates/veld-daemon/src/pty.rs:2529:17, is a link too — clicking it offers View in Veld first, which opens the file pane at that line, and under it whichever editors your project declares for the job (ide.extensions with accepts: "file"). With none declared the click goes straight to the file pane; with one or more you are asked once and the answer is remembered (Open a clicked file path with). Veld checks the path is really a file or directory in the worktree before running anything, and hands it over as a positional parameter rather than building it into a command string, because the path is whatever an agent or a build tool happened to print. What gets underlined is narrow on purpose — a directory or a known source extension, not example.com or v1.2.3 — and Settings → Links turns it off for a project whose output is full of path-shaped prose.
Terminals are polite about finishing. The basics of terminal ergonomics are covered: a BEL rings the bell (volume set in Settings → Terminal), a process can rename its own tab with the terminal title it sets (OSC 0/2), and the OSC 9 "notify" sequence raises a notification — and rings the bell with it, at the same volume, because "notify me" is the stronger of the two sequences and should not be the quieter one. A notification names the worktree and pane — and the project, when the event came from one you do not have selected — so it is navigable across several open checkouts, and clicking it focuses the pane that produced it. The pane is named by its own name — "Terminal 2", or a config pane's label — never by the title the shell set for itself: a shell renames its tab to the command it is running, which is useful in the strip you are looking at and noise in a banner you read an hour later on another desktop — the in-app toast works in the window you are in, and the OS banner (with the same click-to-focus) surfaces whether the worktree lives in this window, another Veld window, or a browser tab. It is deliberately stateless — there is no inbox, nothing is persisted, and it is not a dashboard of what an agent is doing; it is just "something in a terminal finished". Every terminal adopts its own title, a config-declared pane included: for a coding agent that title names the task it is on, which is what tells four otherwise identical agent panes apart. A repo that would rather keep the tab still sets fixed_label on that pane, and the tab stays on its label whatever the tool inside is doing.
Two escape hatches, because a pane is not the browser you are logged into — it has its own cookie jar, so an SSO flow started in one begins from scratch. Hold ⌘/Ctrl while clicking a link to send that one to your real browser. And an exempt list of origins always goes there: yours in Settings → Links, and the project's in its veld.json (ide.externalOrigins), unioned rather than one overriding the other — you name the sign-ins you use, the repo names the ones its app goes through. The whole behaviour is one switch (Settings → Links) if you would rather every link left the app.
Tools that call open/xdg-open directly are caught too, which matters because that is what an agent's shell tool does — Bash(open "https://…") never looks at $BROWSER, and Claude Code even sets BROWSER=true for its children. Those need Veld's shim directory on PATH, and PATH set before your login shell starts does not survive it: macOS's path_helper (in /etc/zprofile) rebuilds PATH with the system directories first, and Debian's /etc/profile overwrites it outright. So PATH has to be set after your startup files, and Veld arranges that without editing any of them:
zsh — Veld points
ZDOTDIRat a directory of its own containing one file, a.zshenv. That file handsZDOTDIRstraight back, sources your real~/.zshenv(whose place in the startup order it took), and registers aprecmdhook that prepends the shim directory. Your.zprofile,.zshrcand.zloginare then read normally, in order, unmodified, with your own$ZDOTDIRvisible to them.bash — bash has no hook that runs after its rc files (
BASH_ENVis non-interactive shells only, and--rcfileis ignored for the login shell a terminal opens). What it does have is posix mode: started with--posix, an interactive bash reads$ENVand no other startup file. So Veld takes that one file, leaves posix mode on its first line, replays your own startup in bash's documented order —/etc/profile, then the first of~/.bash_profile,~/.bash_login,~/.profile— and adds its line at the end, where nothing can rebuildPATHafter it.shopt login_shellstays true throughout, so your files see the shell they expect.…and then
~/.bashrc, which is the one place Veld deliberately departs from bash. A login bash never reads~/.bashrc; the convention is that your profile sources it. macOS ships no~/.bash_profileat all, so a machine with~/.profileand~/.bashrcgets a login bash with none of your aliases or functions — which would make picking bash here load none of the config you picked it for. Veld therefore sources~/.bashrctoo, unless the profile it just sourced already mentions it, so a conventional setup is never sourced twice. Every other terminal emulator runs a plain login bash and stops at the profile; Veld does not, because choosing bash in this picker is a statement about where your config lives.
In both cases Veld owns one file, in its own directory, and never writes to your home.
The shims route a single http(s) URL — or a single local file Veld can show (see Opening a local file in a pane) — and hand anything else to the real tool untouched, with the original arguments: open ., open -a Safari …, open archive.zip, a directory, a flag, two arguments. Everything that is not one of those two things behaves exactly as it always did, and the fall-through is silent — open is a command people run dozens of times a day, and a wrapper that narrated itself would be noise.
bash support is probed, not assumed. macOS still ships bash 3.2 as /bin/bash, and that version ignores $ENV in posix mode entirely — so Veld asks your bash — a ~10ms bash --posix -i -c ':' with $ENV pointed at a marker file, cached per binary — and only uses the handoff on a bash that honours it. A Homebrew or Linux bash 4+ works; the system one on macOS does not.
Everything else — fish, nushell, and a bash too old for the handoff — keeps $BROWSER and can opt in by hand with one line, since every Veld terminal exports the directory:
# ~/.bashrc — route open/xdg-open inside Veld terminals to embedded panes
[ -n "$VELD_SHIM_DIR" ] && PATH="$VELD_SHIM_DIR:$PATH"
You do not have to guess whether it worked. Settings → Terminal → Shell asks your actual shell what open resolves to and, if the shim did not win, says so and shows the exact line to paste and the file to paste it in. veld self doctor reports the same thing, naming the mechanism in force (ZDOTDIR, $ENV, or none) for the shell you chose.
Anything that runs inside your shell's startup gets an off switch: Also catch programs that call open / xdg-open (Settings → Links). Turn it off and the open/xdg-open wrappers leave your PATH; turn off Open links from the terminal in Veld and the $BROWSER half goes too. Both take effect for new terminals; a shell already open keeps the environment it started with.
That one file Veld owns is also where the worktree inbox's command markers come from, and the two features are separately switchable even though they share the mechanism: each half of the file runs only when its own variable is in the environment. The first version gated them together, so turning off Also catch programs that call open silently removed the rail's activity glyph — a coupling nothing in either setting's description implied. With every switch that rides the file off, Veld sets no ZDOTDIR and no $ENV at all.
veld self doctor reports whether this is actually working, because its failure mode is otherwise silent: the little scripts are written once when the daemon starts, and they carry the absolute path of the veld CLI belonging to that daemon — a sibling binary for a dev build, <prefix>/bin/veld for an installed one. A daemon that can find neither (a moved install, an interrupted update) leaves the feature off, and the row says which case it is and what fixes it — restarting the daemon, or reinstalling.
Twelve open panes across four worktrees, and the only way to find out whether the build passed was to go and look at each one. So each worktree's row in the rail carries one glyph for what its terminals have to say, worst-state-first: an ear when something is waiting for you, a triangle when a command failed, a tick when something finished, a spinner when something is running. Not a count — nobody acts differently on three unseen events than on five, so the row's scarce space goes on which kind of news. The pane's own tab carries a dot too, so you can tell which one.
Looking at a pane reads it, and so does typing in it — answering a sudo prompt in place makes the mark go away without a second gesture. To dismiss a whole worktree, Mark read in its right-click menu. Clicking the glyph does nothing special: it selects the worktree, like any other part of the row.
It is an inbox, not a status light. A status light answers "what is this pane doing", which you can see by looking at it. This answers the question you cannot: of the panes I am not looking at, which finished, which failed, and which needs me?
Two things fill it, each with its own off switch, and both on by default:
terminal.shellIntegration). Veld registers two hooks in the shell it opens, which print an invisible marker when a command starts and when it ends, carrying the exit code. So cargo build in a pane you walked away from shows up as finished or failed, exactly, with no guessing. A shell sitting at a prompt never counts, and neither does a watcher that has not ended — pnpm dev and tsc --watch are silent until you stop them. zsh, and bash 4.4 or newer (the same probe as above: macOS's own /bin/bash is 3.2 and cannot carry it).terminal.agentIntegration). Works in an ordinary terminal and in a config-declared pane (ide.panes), for Claude Code, Codex CLI and Pi. None of the three's output says whether it is thinking or waiting — measured: Claude's inline TUI emits a title sequence, hyperlinks and a progress report, and nothing about its state. So being told is the only honest way to know. Veld puts a wrapper on the terminal's PATH in front of the real binary, handing it an extra, throwaway hook configuration; a Claude session stopped at a permission prompt then shows amber beside its worktree, and a turn that merely ended counts as finished, not as waiting — the difference between a badge you trust and one you learn to ignore. Only the session reports, never its sub-agents. A Claude session that farms work out to sub-agents announces each one finishing, and none of those is yours to act on: the reported state is the session's turn, so a sub-agent starting, finishing or failing produces no news and does not touch the pane's running spinner. Its counterpart survives on purpose — a sub-agent that needs an answer still shows as waiting for you, because the question is whether you have something to do, not which agent asked. A session waiting on its own background sub-agents is not finished either: Claude ends its turn to wait and is woken by each result into another turn, so while one is still running the pane keeps spinning and nothing is filed — only the turn after the last one comes back is finished, once, not once per agent. Background shell commands don't hold it, since one can be a server that never ends. Codex tells veld less, deliberately: it also has a richer hooks system with signals as fine-grained as Claude's, but using it means either an interactive "new hook, review required" prompt the first time Codex sees it, or a flag that waives that review for every hook configured, not just Veld's — both worse than the gap. So Veld uses Codex's older hook instead, which fires on exactly one event, the end of a turn: a Codex pane can go straight from to with no signal in between, and that's the trade. : it has no permission prompts, no plan mode, and no built-in tool approval at all, so there is nothing waiting ever fires on — not a choice Veld made, a fact about Pi. What it does have is a run-level pair, and : is when the agent starts working on a prompt, and is Pi's documented signal that the run is fully settled — no retry, compaction retry, or queued follow-up remains — which is the true end of the run. That is the same whole-run shape Claude's / cover minus the permission prompt: a Pi pane goes → → once per prompt, and arrives exactly when the agent actually became idle — not, as a per-turn signal would, after every single step of a run still in progress.An agent can also leave you something to read. veld ide open plan.md --notify opens the file beside the agent's terminal and files an unread event of its own kind — a book glyph on the worktree, a toast with Show, and an OS notification when the window is not focused — read, like the others, by looking at the tab. The toast and the OS notification ride the A coding agent is waiting for you row (activity.notifyAgentWaiting, on by default) and respect Focus mode; with that row off, --notify still leaves the unread mark but pops nothing. --quiet opens the tab and files nothing. See Reading a file in the file pane.
Your own configuration is never edited. No ~/.claude/settings.json merge, no .claude/ written into your project, no ~/.codex/config.toml touched, no ~/.pi/agent/settings.json, .pi/settings.json, or either of Pi's own extension directories (~/.pi/agent/extensions/, .pi/extensions/) touched either. For Claude, the hooks live in a file Veld owns inside its own per-daemon directory, named after the terminal session and rewritten on every launch, and --settings merges into Claude Code's normal hierarchy rather than replacing it — a notify hook of your own in ~/.claude/settings.json still runs alongside Veld's. For Codex there is no file at all: Veld passes -c notify=[...], which overrides that one config key for that one launch. This one is a replacement, not a merge — if you have your own notify configured in ~/.codex/config.toml, it does not fire in a Veld terminal, silently, for as long as this feature is on; there is no cheap way to chain the two without Veld resolving and re-invoking your own notifier itself, which it does not do today. Pi has neither a settings key nor a config override to reach for — the only hook mechanism it has at all is an extension module, loaded with -e <path> — so Veld writes one, into the same per-daemon directory, and loads it once. -e is documented as repeatable and purely additive, the same "nothing of yours is touched" property --settings gives Claude, by a third route: code on disk instead of a JSON merge or a config replacement. All three wrappers are also deliberately hard to reach: each passes through untouched for anything that is not a plain interactive launch (claude mcp …, claude update, claude -p …, codex exec …, pi install …/pi update …/pi -p …, or a settings/config/extension flag of your own), and if it cannot find the real binary, or Veld cannot produce the hook configuration, it execs the real tool exactly as you typed it. codex resume/codex fork count as interactive too, not as subcommands to leave untouched — otherwise the ide.panes resume example just below would silently get no badge.
Claude Code can do without the wrapper — Settings → Activity → How Claude Code is told (terminal.claudeIntegration, wrapper by default, or mod). On mod, both halves above reach Claude through a Claude Code mod instead: a plugin of function hooks that Veld writes into its own per-daemon directory and names in the terminal's CLAUDE_CODE_PLUGIN_DIRS, so Claude loads it for that one process the way --plugin-dir would. Nothing is installed and nothing of yours is written — no ~/.claude/settings.json, no plugin marketplace — and claude itself runs with the arguments you gave it: the claude wrapper still sits on PATH for Codex and Pi's sake but passes straight through. The mod is inert anywhere it finds no Veld session, including claude -p in a Veld terminal. What it reports is the same set of states, with two improvements the wrapper's hooks could not give: a turn you interrupt with Esc stops spinning at once rather than at Claude's idle reminder a minute later, and a question it puts to you with AskUserQuestion or a plan to approve stays waiting for you until it is answered, however often you look. A permission prompt reads as waiting for you only once its dialog is on screen — never for a call auto mode's classifier approves by itself — and stops when you have looked at it or the tool returns. The cost is three signals, and only where an organization's policy applies: on a Team or Enterprise plan, or any machine with managed Claude settings, an MCP server's own question dialog (an elicitation) does not reach the mod, so it does not badge; a permission prompt is told from Claude's permission check instead, which in auto mode also flags calls the classifier then approves (the mod notices, and remembers, as soon as Claude's own hook events do reach it); and the one refused stop that holds Claude to naming its worktree (below) never fires — the instruction on its first prompt still asks. An organization whose managed settings set prependPlugins without sec-default@builtin lets it through, and the mod then holds waiting for you until the question is answered. The text the agent is told rides the first prompt of each conversation rather than the system prompt, because on Team and Enterprise plans Claude Code's built-in policy mod answers the system-prompt event (and every classic.* hook event) ahead of a user's mods — measured on Claude Code 2.1.294. It needs a Claude Code recent enough to load mods, which is why the wrapper stays the default: veld self doctor asks your claude (claude plugin validate on Veld's folder) and fails the row when it refuses, and the IDE offers the switch once, just before the first pane whose runs starts — after asking the same question — so that pane already starts the way you answered. Claude typed into a plain terminal is never asked about. One thing can stop it silently, and names it: a in the block of your Claude settings the terminal's rather than joining it, so the folder has to be added to yours. With , the New worktree dialog's prompt is still sent at launch for a pane whose runs directly — Veld appends it itself, under the same rules the wrapper uses — and typed in by the window for any other pane.
The same wrapper tells the agent where it is — Settings → Activity → Tell coding agents how to show you files (terminal.agentContext, on), a switch of its own: either half works without the other. A few static sentences, the same in every session and every project, say that this is a Veld terminal, that the agent's first action in a new conversation is to name its worktree (veld ide worktree name, renamed again only if what it learns next shows the first name was off — never later), that veld ide open puts a file or a page next to it, and that separate work the agent comes across can be offered to you as a hand-off (veld ide worktree new) rather than done or noted for later — spelled as the veld that belongs to the Veld running the terminal: the bare word for the installed one, the absolute path of the binary for a development build, whose agents would otherwise run the installed release and reach the wrong daemon. They are appended to the agent's own system prompt, never replacing it: Claude gets --append-system-prompt, Codex -c developer_instructions=…, and Pi a second extension that adds a section to the prompt it was going to send. The same rules hold — a plain interactive launch only, and your own flag wins: --append-system-prompt or --system-prompt of your own keeps Veld's text out (and leaves the badge alone). Codex's override would replace, so if your ~/.codex/config.toml (or $CODEX_HOME's) mentions developer_instructions anywhere, Veld leaves the text out rather than take yours away; a project-level .codex/config.toml is not read, and one that sets it is replaced for that launch. Text this short and static is cheap for the agent — it is identical every turn, so it caches — and a resumed Claude conversation keeps whatever prompt it started with.
A third switch, Show what is working (activity.showWorking, on), adds a spinner for a worktree with a command running in it. Claude reports it properly: its wrapper says "an agent lives here and it is idle" before it even starts — the one fact only the thing launching it knows, and what stops a pane running an agent looking like a pane running a long shell command — and a hook then says when each turn begins. Codex only gets the first half. Its wrapper reports the same "idle" launch, but nothing ever tells Veld a Codex turn started — so with this switch on, a Codex pane shows no spinner for its entire working phase, not just before its first prompt. That is worse than the plain shell fallback this setting replaces (which would have spun for as long as codex stayed running), and it is the direct cost of the notify-over-hooks trade above. Pi gets both halves, like Claude: its wrapper reports the same idle-at-launch, and its agent_start event says when each run actually begins — a different mechanism (an extension event, not a hook) landing on the same result. An agent Veld has no integration for at all still reports nothing either. It ships on anyway, because the failure is a missing spinner rather than a wrong one and the build case — "is that still going?" — is common enough to be worth showing everybody; expect it to under-report for Codex specifically, and turn it off if a partly-blank signal is worse than none for you. Either way it is the quietest thing the rail shows: it loses to every unseen event, so a worktree with an agent waiting still reads as waiting.
These switches are independent of each other and of the link-routing pair above, even though they reach your shell through the same one file Veld owns. Turning off Also catch programs that call open does not turn off the rail glyph.
And it can tell you when you are somewhere else. Settings → Activity → Notifying is a five-row table — a command finished, a command failed, a coding agent is waiting for you, a coding agent finished, a program asked to be noticed — because those are not the same event and one switch for all of them is wrong in one direction or the other. The agent rows say agent on purpose; the last row is any program ringing the terminal's notification sequence, which Veld cannot tell apart from one merely waiting for input (that is not observable from a browser at all, so a program has to say so itself). Everything is on except a command finished, which is news the rail already carries rather than a reason to interrupt you. One channel, chosen by where you are: a focused window gets an in-app toast — the right weight for something you can already see — and an unfocused one gets the OS banner, which is the only thing that reaches across windows and apps. Never both, and clicking either focuses the pane. Worth knowing before you leave a coding agent finished on — for Claude and Codex the end-of-turn signal fires after every response, so a long session can ring more than you expect; Pi's fires once per settled run instead, so a Pi session does not.
And it reaches across projects. A coding agent's state is reported to Veld by the agent itself, so it arrives wherever you are — including a window that has a different project selected. Such a notification names the project as well as the worktree and pane, because branch names and rail markers repeat across projects by design (two projects both on main is the ordinary case), and clicking it switches project, selects the checkout and brings its pane to the front. There is one honest limit: what a shell reports — a command finishing or failing, a program ringing the notify sequence — is read by the terminal in the window that has that pane open, so a project no window is showing at all produces coding-agent events only. Two windows, one project each, is unaffected: each window reads its own panes.
And there is one button that takes you there — Next unread, at the end of the top bar (⌘⇧J in Veld Desktop). The rail's glyph tells you a worktree has news, but only once that worktree's project is the one selected; the project column's dot says something is happening elsewhere without saying what or where. Next unread closes that gap: it goes straight to the pane — one a coding agent is waiting in, or one where something finished or failed — switching project, selecting the checkout and bringing the pane to the front, exactly as clicking the notification does — and it does that from either view, IDE or Runs. Waiting first, then a failure, then a finish, the same order the glyphs already rank in: a blocked agent is stopped work and a finished one is not, however recently it finished. Within one of those, the most recent goes first — the button appearing and the place it sends you are the same event, so it never opens something you dealt with an hour ago in response to something that just happened. The word is coloured by which kind it is, so you can tell before you read the tooltip — which names the worktree and pane you are about to land on, and the project too when it is not the one already on screen. Arriving reads the event, which is the whole of the "next, then next" behaviour: there is no cursor to get out of step, and pressing it until it goes away is how you clear a morning's worth of finished agents. It is only there while something is waiting — there is no idle state to render, so there is no greyed one either, and it does not follow hide inapplicable actions the way its neighbours do: that setting picks between hiding and greying a control that exists either way, and this one has no such control to pick for. Settings → General → Show the Next unread button turns it off entirely; ⌘⇧J keeps working either way, since a chord costs no room in the bar. Pressed from the Runs view it switches to the IDE once the jump lands — a pane is what it takes you to, so it only changes view when there is something to change it for. It is subject to the same honest limit as the paragraph above — a worktree open in another window is reachable, and that window is raised rather than the pane being stolen.
Veld Desktop's Dock icon counts the same things. On macOS the app's Dock icon carries a red badge with the number of unread events — the exact set Next unread walks, across every project and every Veld window — so you can tell from another app that an agent is waiting or a build finished, and the number counts down as you walk them. A pane open in two windows is counted once. Veld's own news (the What's new cards) is never counted: the badge means something of yours needs you, and a release note is not that. Settings → General → Dock icon badge picks a number (the default), a plain dot that only says there is something, or nothing at all — or veld self settings set desktop.dockBadge dot.
Focus mode is the top-bar toggle for all of that, between search and settings (focus.enabled, off). On, it silences whichever of three channels Settings → Activity → Focus mode has checked — the terminal bell, the in-app toast, and the OS banner — all three checked by default the moment the master switch goes on. It reaches exactly the channel described above and nothing else: a toast reporting something you clicked yourself (a failed action, a copy confirmation) is not an interruption from elsewhere, so it is never gated by this. A suppressed toast or banner is discarded, not queued — both ride the same OSC 9/133 marks the rail glyph is built from, so the rail is already the permanent record that something happened, and a second one held back for a summary popup is more likely to be ignored than read once it finally appears. The bell is different: a plain terminal BEL was never one of those marks and never reached the rail, on or off — silencing it removes exactly what it always was, a sound in the moment and nothing more. And "OS-level" means only the banner Veld's own notification path would have raised (Veld Desktop's native notification, or a browser tab's Web Notification) — it is not a system-wide Do Not Disturb, and nothing else on the machine is muted.
And if you are walking away, the coffee cup beside search keeps the machine awake. A run that outlives your attention — an overnight agent, a long build, a watcher — dies when the laptop suspends, and the machine suspending is not something a run can do anything about. The button is a menu rather than a plain toggle, because how long is the interesting half: 30 minutes, 1, 2, 4 or 8 hours, or until you turn it off. Turning it off is the first item in the same menu once it is on, and the tooltip carries the time remaining. It is machine-wide, not per run and not per window — the thing being held awake is the laptop — so every browser tab and every Desktop window agrees, and switching it off in one switches it off everywhere.
Under it, veld holds the inhibition the way each platform supports: caffeinate -s -i on macOS, and a systemd-inhibit lock over handle-lid-switch:sleep:idle on Linux. It can never outlive the daemon — the inhibitor is wrapped around a pipe veld holds, so the daemon exiting for any reason, including being killed outright, releases it; nothing is persisted, so a reboot never comes back with the machine pinned awake by a choice you made last week. On a machine with no way to ask at all (a Linux box without systemd) the button is greyed with the reason rather than failing on click.
A closed lid on battery is the one case that needs root, and only on macOS. caffeinate -s is valid on AC power only, and macOS offers no unprivileged API for the battery case at all — the single lever is pmset -b disablesleep. So veld reaches it through the privileged helper, when you have one: with veld self setup privileged the coffee menu covers a shut lid on battery too, and says so; without it, the menu says the machine will still sleep on battery and names the command that changes that. Linux needs none of this — handle-lid-switch is a real logind inhibitor and holds on battery already.
And it does this by itself while you are sharing. A share is only useful while the machine serving it is up, so starting one arms the keep-awake for you — Settings → Keep awake, on by default, and asked twice because the honest answer differs by where your power is coming from. On mains it holds for up to two hours and covers a shut lid, which costs nothing there: the macOS flag for a closed lid is valid on AC power only, so no privileged helper is involved. On battery it holds idle sleep only, for up to thirty minutes — a laptop that goes in a bag mid-share sleeps exactly the way it always did. Each cap is a ceiling, not a countdown: shares expire on their own, so the hold normally just ends when the share does. Unplugging or plugging in re-reads the answer and starts that allowance again, which is what stops the battery limit being sidestepped by starting a share on mains and then walking off with the laptop.
Two deadlines are in play, and the cup now says which one it is counting. The hold ends at whichever comes first: the cap above, or the share link's own expiry. With the defaults that is the cap — a two-hour mains cap under a four-hour peer link means Veld holds the machine awake for two hours and the link keeps working afterwards, for as long as the machine is up anyway. The two answer different questions, which is why they are not the same number: one bounds what Veld does to your hardware unasked, the other bounds how long a colleague has to open a link.
When the share is the shorter one — you passed --ttl, the project shortened it, or you raised the cap past the link's life — the cup, the sharing panel, veld network share and veld self doctor all say so, because a countdown that does not match the number you set in Keep awake is otherwise indistinguishable from that setting being ignored. The link's life is a setting of its own rather than a constant buried in the daemon: Settings → Sharing, or veld self settings set sharing.peerTtlMinutes 480. A project can set it for everyone who checks it out (sharing.peer_ttl_minutes / sharing.web_ttl_minutes in veld.json), and veld network share --ttl still overrides everything for one link.
An automatic hold never asks for the privileged helper, on either power source — that is the line that makes it safe to have on by default. pmset disablesleep is durable (see below), and there is no button-press behind an automatic hold to justify writing one. So the strongest thing veld will ever do without being asked is stop the machine dozing off; keeping it awake with the lid shut on battery stays something you ask for, one click on the cup. The cup says which of those you have: it names sharing as the reason, carries the time left, and says when the automatic allowance for a share is used up rather than quietly going out. Turning it off there means off — it will not re-arm itself for the share you turned it off during.
Outside the IDE it still tells you. veld network share prints an Expires: line saying when the link itself dies (the lifetime actually in force, whichever of --ttl, the project's veld.json or your setting decided it) and an Awake: line beside Stop: saying whether the machine will still be up to serve it, and veld self doctor reports what is holding it and why — which is the one that answers "why won't this Mac sleep" hours later, from a support transcript, with no window open. There is still deliberately no keep-awake subcommand; both of those report state rather than changing it.
That lever is a durable system setting rather than an assertion, which is the one thing worth understanding about it: set it and walk away and you have a Mac that never sleeps, with nothing running to explain why. So veld never simply sets it. The helper holds it on a short lease that the daemon must keep renewing — the daemon renews every 30s against a 90s lease, and the helper's watchdog puts the setting back once that lease runs out — so a blip costs nothing and a daemon that is really gone costs at most the remainder of the window. A daemon that is killed, wedged, updated or uninstalled stops renewing and the Mac sleeps again on its own; a helper killed mid-lease picks the hold back up when it restarts and gives the daemon a short window to resume renewing — if none does, the same watchdog hands the setting back, and that window is bounded across restarts so a crash loop cannot keep renewing it. Every failure path lands on "the machine can sleep", which is the only safe direction for a setting that survives a reboot. If the lease is ever lost while the keep-awake is still on, the menu stops claiming battery coverage instead of quietly leaving you with a promise nobody is keeping. And if you would rather veld never touched that lever at all, Settings → Keep awake → Cover a shut lid on battery too turns it off — which is a guarantee rather than a preference: veld then never writes pmset disablesleep on this machine on any path, and a shut lid on battery sleeps.
Config-declared panes work too, and they get there differently. An ide.panes entry runs as <shell> -l -i -c '<command>', and -c never prints a prompt — so the OSC 133 hooks, which fire around a prompt, never run in one. A custom pane therefore reports finished/failed from its process exiting rather than from a command mark, which is the same answer by another route. What it does get is the agent half: Veld prepends its shim directory to PATH inside that command. That line is a zsh fix and a no-op for bash, measured both ways — bash puts the directory on PATH with a plain assignment in the file it reads, which a -c shell reads fine, while zsh does it from a prompt hook that never fires. Without it, a pane declared as {"argv": ["claude"]} under zsh silently bypassed the wrapper and reported nothing: the coding-agent half was off for exactly the panes most likely to run an agent.
Unread events survive a page reload — kept in the tab's own sessionStorage, because a reload is exactly the moment you were not looking. What does not come back is the live "something is running" state: that is a claim about a process right now, and a reload is when it stops being knowable.
What it cannot do. The markers are read by the terminal in your window, so events exist only while a window is open: close the tab and its news is gone with it. The running spinner follows the session's work, and for Claude and Pi work only ever begins with a prompt from you — that is the only start signal either offers that doesn't fire once per tool call, and Codex offers none at all. So a turn that starts for some other reason, most visibly an agent picking work back up after a background command it left running, reads as finished until you next type something — except while a background sub-agent is still running, when the pane keeps spinning (see above). It is the spinner that is missing, not an event: nothing false is filed, and the next real end-of-turn reports normally. Pi never reports waiting for you at all — it has no permission-prompt system to observe, so a Pi pane can sit at a genuine question with the badge still saying finished from its last run; there is no signal to promote instead, unlike Codex's deliberate notify-over-hooks trade above. Failed is command-only — none of the three agents exposes a clean per-turn failure event. Claude reads as waiting for you when it errors, which is what it usually is; Codex and Pi both read as finished regardless of outcome, because each one's end-of-run signal (notify, agent_settled) fires on completion without distinguishing success from failure, and neither has a waiting signal to fall back on either. Codex's payload also briefly travels as a command-line argument rather than on a pipe — Codex builds it that way, not Veld — so for the moment veld agent state is running, it (and the short conversation preview Codex includes in it) is visible to anything else on the machine that can read a process list, the same as any other program's arguments; Claude's stays on a pipe the whole time. Pi's payload travels the same way Codex's does, but that one is Veld's own choice rather than Pi forcing it, and there is nothing in it more sensitive than an event name and a shutdown reason. And in the one setup where no command mark ever arrives to hand the pane back — shell integration off, or a shell its zsh/bash-4.4+ half does not cover, so nothing tells Veld a Codex session (as opposed to a turn) has ended — the pane keeps a hook's claim on it for good once Codex has spoken once, even after you quit codex and go back to typing plain commands in the same shell. It still reports each turn correctly while codex runs; what it loses afterwards is the working spinner (see above) and any later terminal-bell notification in that same pane, which a hook-claimed pane never shows. Quitting the shell, or closing and reopening the pane, both clear it. Config-declared panes, and any shell actually covers, are not affected — both have another way to learn the session ended (the pane's own process exit, or a command mark). : its event fires with a real reason when you actually quit (, and — which continue in the same pane — are told apart from a real quit and correctly report nothing), so a Pi pane hands itself back the ordinary way and never needs the shell-exit fallback at all.
Which shell a terminal opens is Settings → Terminal → Shell, and it defaults to Automatic — your login shell, the one $SHELL names. Pick another when that is not the shell you actually work in: macOS has shipped zsh as the login shell since Catalina, so someone whose aliases, completions and tool integrations live in ~/.bashrc was getting a terminal that loaded none of them. The list is what this machine has (/etc/shells plus what is on PATH), and Custom path… takes an absolute path for anything not on it. Veld never guesses from your rc files — a stale ~/.bashrc sits in nearly every home directory, and switching a contented zsh user's terminals on that evidence would be worse than the problem. The choice applies to new terminals and to config-declared panes; a shell already open keeps the one it started with. It is also the shell veld consults to learn your PATH for commands it runs on your behalf, so a PATH you build in .bashrc is found too.
Settings (⌘,, or Settings in the ⋯ menu at the end of the top bar) covers which shell terminals open, terminal font, cursor, scrollback, bell volume, the Shift+Enter behaviour, how a dropped connection auto-reconnects (how many attempts, how soon the first is, and the backoff between the rest), what Veld notices about finished commands and waiting coding agents, what it shows in the rail, and which of those send you a system notification, whether terminal links open in a pane, whether file paths in the terminal are clickable, whether open/xdg-open are caught as well, and which origins are exempt, how long detached shells are kept, whether the project column is shown, whether the top bar carries the Next unread button, what Veld Desktop's Dock icon badge shows, how worktrees are marked in the rail, where new worktree checkouts are stored, how long trashed worktrees are kept before they are deleted for good, how far back the run history views reach, which time zone log timestamps are shown in, which quick switches a browser pane's toolbar carries, and whether top-bar actions that currently can't fire (restart with nothing running, the machine-vars button for a project that asks for nothing, a URLs button with nothing to open) are hidden rather than shown greyed. They sit in five groups picked from a sidebar — General for the settings that belong to no larger surface (markers, the trash horizon, the history horizon, the log time zone, the hide-vs-greyed choice), then Git (which mode the New worktree dialog opens on, where a new worktree's branch starts from, and where new checkouts are stored), Terminal, Links and Browser panes — so one group is on screen at a time rather than every setting on one scroll; on a narrow window the sidebar becomes a select. They are stored by the daemon rather than the browser, so Veld Desktop and a browser tab against the same daemon agree, and every window sees a change. There is no Save button — each control writes as you change it.
Creating a worktree starts with the work. New worktree… opens on two modes — Start with a prompt and Start with a name — and the first time you open it, that choice is all it shows, with a line on what each one does. Whichever you pick becomes your default (worktree.newMode, also in Settings → Git); the control stays at the top of the dialog, so switching is one click and re-records. Start with a prompt asks which of the project's agents (ide.panes) should pick this up and what needs doing — the agents being the panes that hold a conversation, which veld reads from their resume command unless the pane says otherwise with agent — and on Create the checkout opens with that agent already running on your prompt as its first message, with the name, branch and marker chosen for you. The agent you used is remembered per project, so the next create in this repo opens on it. Start with a name is the older dialog: name it, choose where it starts from, pick its marker, and nothing is run. Nothing in either mode is required, the prompt included: leave it empty and you get the checkout on its pane chooser, exactly as before. ⌘/Ctrl+Enter in the prompt creates it without reaching for the button, since plain Enter is a newline there. The prompt is a draft: closing the dialog by accident — Esc, the scrim, the ⋯ close — keeps what you typed, per project, and so does reloading the page. It is cleared by a create that actually sends it, so typing a prompt and then making a plain checkout instead leaves it waiting for next time; clearing the field forgets it. The prompt can carry files — a screenshot, a JSON fixture, a log — the way a file dropped on an agent's own input does: drop them anywhere on the dialog, pick them with Add files under the field, or ⌘/Ctrl+V a screenshot into it. Each one is shown as a tile under the field — an image as itself, anything else by its name — with its size on it, its path on hover, and a ✕ to take it back, and they go in after your text as the agent's first message, so the agent reads them as it would any file you had dropped on it; files alone, with no text, are enough to start it. Claude Code opens with that message already sent: with terminal.agentIntegration on, veld's claude wrapper passes it at launch, each file as an @"path" mention (which attaches an image as an image), so nothing is ever seen being typed. Any other agent — or Claude with agent integration off — gets it typed into its input once that is ready, as a paste per file and then Return. Exactly one of the two happens: the message waits in a one-shot file the wrapper and the window both try to claim, and whichever is second finds it gone. In the desktop app the file's mention is its own path — except for a file in a temporary folder, such as a screenshot dragged straight from macOS's corner thumbnail, which is deleted before the agent could read it: its bytes are read at drop time and handed over as a copy, and its row says so. A browser tab is never told where a file lives, so it uploads a copy instead, just before the agent's terminal starts — which is why a browser tab refuses a folder, an empty file or one over 32 MB up front, rather than after the checkout is made. Up to 20 files per create; unlike the text, they are not kept in the draft. Everything the dialog used to ask for is still there under , and the name is now optional — left empty it is taken from the first clause of your prompt (), or numbered () when there is no prompt either, and moved out of the way of a name or branch a sibling already holds rather than reported as a clash. Type a name yourself and it behaves as it always has: the rail name () and the branch are derived from it and both are shown in the dialog before anything is created, because both derivations drop characters a git ref or a hostname cannot carry. Umlauts and accents are transliterated rather than thrown away — becomes , becomes — since is what a German speaker writes when they have to reach ASCII, and is not. The branch follows the name until you type over it, and clearing that field hands it back to the derivation. The rail marker is picked in the same dialog rather than on a second trip through the context menu, and opens on a colour and glyph — random rather than "the next unused one", which proposed the same marker for every new checkout in a repo and made a week's rail look dealt off the top of the deck. If the name you typed collides with a checkout this repo already has, the dialog says so before the button rather than quietly appending a .
A checkout can start from four places. Start from is the first thing the dialog asks, and a new branch is the default — that is what almost every create is, and the other three are for the cases it cannot express. An existing local branch checks out a branch git already has, named exactly, with the ones already checked out somewhere else marked as such and refused before the button (git allows one checkout per branch, and the refusal used to arrive as git's own message after the fact). A remote branch fetches that remote and cuts a local branch tracking it, so the checkout starts at what the remote has now rather than at the last poll; a remote branch whose local counterpart already exists says so, and points at the local-branch option instead. The lists come from the refs already on disk — reading them never fetches — so a branch pushed seconds ago may not be listed yet. origin is the one kept fresh for you: the repo poll fetches it about every five minutes, while the list covers every remote, so a second remote's branches appear once you have fetched it yourself. The checkout is current either way, because creating one fetches the chosen ref's own remote first.
And the fourth is another worktree — a spin-off. Another worktree cuts the new branch from that checkout's HEAD, so its commits come along including the ones it has never pushed, and Carry over uncommitted changes (on by default) reproduces the rest: staged, unstaged and untracked files, with the staging intact rather than flattened into one pile. Ignored files are deliberately left behind — a node_modules or a target is tens of gigabytes and is not work. The source is never modified, not its working tree and not its index, so spinning off a checkout an agent is writing in cannot disturb it — and if something does write it while it is being read, the toast says the two moments were mixed rather than pretending otherwise. (One thing genuinely cannot travel: an uncommitted submodule pointer move, because git worktree add does not populate submodules in a new checkout at all. The index records the same commit the source's does; it is the un-staged move that has nowhere to land.) A checkout in the middle of a merge is refused before anything is created, because a conflict cannot travel without the merge it belongs to. The same thing is one click from a worktree's ⋯ menu → Spin off…, which opens this dialog with that checkout already chosen and files the result into the same group.
New worktrees are never born behind the remote. By default a created branch is cut from origin's latest default branch (Settings → Git → Create worktrees from): veld fetches the remote first and bases the new branch on origin/main, so a checkout does not silently start from a main that has not been updated in weeks — missing the latest database migrations and guaranteed to conflict with open PRs. Set it to Local main to cut from the main checkout's current HEAD instead (offline, or deliberately basing on un-pushed local work); offline, veld falls back to origin's copy from the last fetch, then to the local default branch, then to the main checkout's HEAD, rather than failing the create. The dialog says which it is doing. Default branch means the repo's — what origin/HEAD points at, else main, else master — not whatever the main checkout happens to have checked out, so with the default setting, leaving the main checkout on a feature branch does not make every new worktree start from it. Local main does start from that checkout, whatever it is on, and the dialog names the branch it is on.
The base is on screen in both modes, and one pick away from the default. Starts from is a searchable list of every local and remote branch with the default branch first, shown next to the agent in Start with a prompt and under Start from for a new branch. Any other base — a branch you picked, or the row Spin off… was opened on — tints the field and says so, and picking the first option puts it back on the base the setting names. A branch picked there is used as it is, without fetching.
Where new checkouts land is a setting, not a hardcode (Settings → Git → Worktree storage location). Next to repository is the default and today's only past behaviour: a checkout lands in a _worktrees folder beside its repo. Custom location points every new checkout, for every repository, at one folder you choose — pick it with the same native folder picker Import repository uses, or open it straight from the setting once it is set. Either way each repository gets its own subfolder there, named from its directory and a short hash, so two repositories that happen to share a folder name can never collide on the same checkout path. Changing this only ever affects worktrees created from now on — nothing already on disk moves.
One click keeps main current. The top bar shows how far the repo's main checkout has drifted from the remote — a refresh icon with a count of commits behind origin/main, coloured by severity (green for few-and-recent through orange to red for many-and-old) — and clicking it fetches and fast-forwards the main checkout. How sensitively that pill colours is the project's call: ide.git.stalenessSensitivity (default 1) scales the curve, so a fast-moving trunk sets 2 and a project whose worktrees naturally drift sets 0.5 (see configuration.md). It is deliberately human-initiated (never scheduled), refuses a repo root that is not on the default branch, a dirty tree, or a root with a live run, and only ever fast-forwards — a diverged branch is refused, never rewritten. When the bar is set to hide inapplicable actions (Settings → General → Hide top-bar actions that can't fire), the button disappears once you are up to date; with that off, it stays, greyed with "Up to date".
Which checkouts are holding work that isn't safe yet. A rail row carries a small glyph when git says this checkout has something you could still lose: a pencil for uncommitted changes, a broken link for a detached HEAD — not on a branch, so new commits belong to none — and an arrow up for commits that have not been pushed. Uncommitted work ranks first, because it is the only one describing work that exists nowhere else. Hover for the detail, including how many commits and in which direction. It renders in the collapsed rail too, because "which of these still needs me" is a question people ask with the rail narrow.
Those three, and nothing else. A checkout that is clean and fully pushed shows nothing — a glyph means "this one is holding something", so a mark that appeared on every tidy row would cost the column the only thing it is for. (A branch glyph for the pushed state was built and cut for exactly that reason: it says nothing about unsaved work, and on the main checkout, which never leaves that state, it was lit permanently.)
It shares one slot with the activity glyph, and activity wins. Not for want of room: while an agent is running, the tree is expected to be dirty, so a pencil beside a spinner reports what the spinner already implies. The same holds for the rest of that vocabulary — those states are news that arrived and has not been read, and news outranks a standing property of the checkout. Once you have read it, the git state takes the slot back. The tooltip is not folded, so a spinning row still tells you it has three unpushed commits — the glyph collapses, the facts do not.
Veld never claims a pull request exists, or does not, or was merged. A pull request is a forge object and no git command can be asked about one. A branch whose upstream has been deleted — what a merged-and-tidied pull request leaves behind — renders nothing: git cannot tell that apart from a pull request closed without merging and then deleted, and a mark that is confidently wrong about which teaches people to distrust the whole row. When veld can see the upstream is gone it says so in the tooltip, where a sentence is allowed to be probabilistic; it will not draw a glyph on it. It only sees that once your own git has pruned — git fetch --prune, git remote prune origin, or remote.origin.prune = true — because veld's background fetch deliberately does not prune your refs for you.
Real pull-request state is an ide.extensions status badge, which holds a PR number and can say it properly — veld's own veld.json ships one that runs gh and shows PR #283 · merged in the top bar.
The cost is kept off the thing that draws the rail. The push half — ahead, behind, upstream gone — is one git for-each-ref per repository, which reads refs and never enters a working tree, so it rides the poll and is always current. The uncommitted half needs a git status per checkout, so it runs behind the poll on a rate limiter and the row shows no glyph at all until it has an answer. Note that a clean checkout shows no glyph either — absence means "nothing to see here", the same as everywhere else in the rail — so on screen "not yet measured" and "tidy" look alike. They are still separate values underneath, which is what stops veld ever asserting a checkout is clean on the strength of a reading it did not take. Nothing runs while no window is open.
Organising the rail. With a dozen checkouts, alphabetical stops helping. Groups are sections you name yourself — review, spikes, client-x — created from the rail's folder button or a worktree's context menu → Move to group, and a worktree is dragged between them — or created straight into one, since every group header carries its own +; a checkout created that way lands at the top of the section you started it from, which is where the thing you just made belongs. Within a group you can drag worktrees into any order you like, and the groups themselves are dragged by their headers to reorder the rail — including above Worktrees, the section holding everything you have not filed into a group, which moves like any other section rather than being pinned to the top — the same move the group's ⋮ menu offers as Move group up / Move group down, which is still there for the keyboard. Anything you have not placed by hand stays alphabetical underneath. A whole section can also be moved or cleared at once: the ⋮ menu offers Move all worktrees to…, which files every checkout in that group into another one — an existing group, a new one you name right there, or back out of any group — and Move all worktrees to trash…, which bins them all. Both confirm first and list exactly what they will touch; the trash one also says which of those checkouts have uncommitted changes, since binning keeps them but a later permanent delete will not — it holds the button for a moment while that check runs, and keeps reporting what it finds after releasing it, so one checkout git is slow to answer for cannot hold up the rest. The Worktrees section carries the same two entries, which is how a repo that never defined a group files its whole rail into one. The main checkout is never binned. A detached checkout is no longer an exception to any of this: it stays in the group you filed it into and a batch acts on it like any other row, with its state shown by the glyph on the row rather than by moving it somewhere else. The rail is also resizable — drag its right edge, or focus it and use ←/→ — and the width is remembered per window, because two windows on two monitors want different rails. Dragging cannot narrow the rail into its collapsed mode: collapsing hides the name and the branch, so it stays a deliberate click on the chevron rather than something a drag can do to you by accident.
And a section you are not working in folds shut. Click a group header — anywhere along the bar, or the disclosure triangle at its far left — and that section closes: the header stays, its rows go, so a rail with six groups is as short as the one group you are in. A folded header carries what the hidden rows would have said, in the language the rows say it in: how many are behind it, the same warning triangle if a hidden checkout's run has failed or is one veld is recovering, the same activity glyph if one has terminal output you have not seen, and the row's own accent when the checkout you are in is one of them — so folding a section never loses where you are. Those are indicators, not buttons: a section speaks for several checkouts, so folding is the only thing the bar itself does — the + and the ⋮ sitting on it keep their own meanings. Nothing springs open. Drop a worktree on a folded section and the drop lands, the count ticks up and the section stays shut; the same for one created from its +, which is what the accent on the header is then telling you. This is the rail's other collapse, and the two are independent: narrowing the rail (the chevron above) draws no headers at all, so there is nothing left to fold there, and widening it again finds every section exactly as you left it. Folds are remembered per repository, on this machine — they survive a reload and a restart, and two windows on the same project agree with each other as you fold — but unlike groups and order, which are the daemon's, they do not follow you to another machine.
Groups and order are yours, not the repository's. Nothing is written to veld.json, so your private rail layout never turns up as a review comment on someone else's pull request. (The project-declared counterpart already exists and is a different thing: ide.quicklinks is for links every checkout of the repo should see.)
Worktrees an agent hands you wait in Waiting for you. A coding agent that comes across separate work worth its own branch can offer to hand it off, and with your go-ahead run veld ide worktree new --branch fix/x --name "Fix x" --prompt-file task.md instead of filing an issue for later: Veld creates the worktree and files it in a Waiting for you section right under the main checkout, shown only while it holds something — or, with --group, straight into one of your groups (veld ide worktree groups lists them). Either way the row carries an hourglass until its agent has started, and hovering it says what opening it does. Open one and Veld first shows you the agent's prompt — edit it, Start the agent, keep it for later with Not now (click the row again to get back to it), or Move to trash — the prompt goes with it, so restoring the worktree from the trash brings the review back. Starting runs the agent pane it named (or, if that checkout does not declare it, the one you usually pick in this project, and the review says so) with the prompt as you left it, and from then on it is yours. Tick Start prompts from agents without asking to skip the review from then on (Settings → Git → Review prompts handed off by agents, worktree.reviewHandoffs, on by default); nothing reports back to the agent that handed it off. Rows there are ordinary worktrees (run, rename, bin them as usual); drag one into a group, or onto Worktrees, to file it, and it stays where you put it. Nothing can be dropped into the section. If a window closes or reloads before the agent has started, the next window to open the worktree starts it.
The agent names its own worktree. Every agent in a Veld terminal is told to run veld ide worktree name "<a few words>" once it knows what the task is — early, and again at most once more if what it discovers (a PR, a linked issue) changes the picture, never later. It sets the rail's label only (at most 18 columns), never the alias or the branch. A name somebody chose is kept: the command refuses on the project's main checkout, and on a worktree already named — by you (renamed in the window, or typed in the create dialog), by a hand-off, or by an agent in another terminal — so opening a second agent tab in a worktree does not retitle it after whatever that tab was asked. Only the conversation that named it may correct it, once; a new conversation in the same terminal may not. A name the create dialog borrowed from its prompt does not count; clearing a name in the rename dialog hands naming back to the next agent. Names that existed before this rule count as yours, except the dialog's numbered default (Workspace 3). With Notice when a coding agent is waiting for you on as well, Claude is held to it: its first prompt in a new conversation carries a one-line reminder, and if that first turn still ends unnamed, Veld refuses the stop once so it names the worktree before finishing (on the mod, where Claude Code's built-in policy mod is seated — typical on a Team plan — the refusal cannot reach Claude, so only the instruction asks). A resumed conversation is left alone, nothing is nudged in the main checkout or a worktree already named, and nothing is ever nudged after the first turn.
Worktrees made outside Veld wait in Discovered. Veld lists every checkout git worktree list reports, so a worktree a coding agent or a shell creates shows up on the next poll — but not among yours. It lands in a Discovered section docked at the bottom of the rail, above the trash, shown only while it holds something, with its rows dimmed. Adopt it (the ⊕ on the row, or drag it into one of your groups) and it joins the rail like one you created; leave it and it stays where it is. Worktrees you create in Veld, the main checkout, and every checkout a repository already has when you import it are adopted from the start — and so is every worktree Veld knew about before this section existed. Until it is adopted, adopting is all the IDE offers for it: it cannot be opened, started, renamed, spun off from or binned there, ⌘K and keyboard stepping pass over it, and its menu holds Adopt into the rail alone. (This is the rail's rule, not a lock — the daemon's API and veld itself still work in that checkout.) Adoption sticks to the path: a worktree that drops out of git worktree list for a while — say, on a drive that was unmounted — comes back adopted rather than as a discovery (though, like any worktree that briefly vanished, ungrouped and under a fresh name and marker).
Removing a worktree puts it in the trash. Move to trash… marks the checkout and hands you straight back the app — nothing is deleted, the directory stays on disk, and the row moves to a Trash section at the bottom of the rail, which starts folded — a pile you mean to forget about should not cost you rail height. Unfolded it shows the two you binned most recently and a +N for everything else, which opens the rest when you click it: the trash has no upper bound, and without that a long-lived one would push the groups you actually work in off the top of the rail. Restoring a worktree from there is a real undo, not a race. A trashed worktree is also searchable from ⌘K: a query that matches one lists it under Trash, below every other match, and choosing it restores the worktree and opens it — so does pressing Enter when every match is in the trash. Its terminals keep running for 5 minutes after it is binned, so undoing a mistake finds every shell where you left it; after that they are closed so a pile of trash cannot use up the terminal limit, and restoring brings the tabs back with new shells in the same directories — an agent pane offers to resume its session (Close terminals in trashed worktrees after, Settings → Terminal; 0 keeps them running until the worktree is deleted). That also makes the operation instant: awaiting git worktree remove on a large checkout is what froze the UI before, and now the request does no slow work at all.
A trashed worktree is deleted for good when you say so — Delete permanently on its row, or the trash-can button on the Trash header to empty the whole thing — or automatically once its retention period runs out (Settings → General). That setting is 0 by default, which means keep until I empty it: the only thing veld ever deletes without asking twice is something you put in the trash yourself and then left there for as long as you told it to.
Nothing else empties the bin. Restarting the daemon, quitting Veld Desktop, rebooting, running veld self update — a trashed worktree survives all of them, for as long as the retention period says. The one thing a restart re-checks is the retention period itself, so trash that expired while veld was not running is collected when it comes back.
Deleting always goes through git worktree remove, un-forced. Any run in the worktree is stopped first. If git refuses — uncommitted changes, a locked worktree, a file open in another process — the worktree comes back into the rail with the reason on its row, and says so once in a toast; it is never a silent failure. Forcing (which discards uncommitted changes) is a second, explicit click, offered only after git has already told you why it said no. The branch itself is always kept.
Worktree markers are a colour by default, or the animal emoji if you prefer, or Pixels — a small mirrored 3×3 pattern drawn in the worktree's colour (Settings → General, or the switch at the top of the marker picker). Both a colour and a glyph are stored for every worktree, and Pixels is drawn from the two — each animal has its own pattern — so switching between all three never loses the one you picked, and either half can be chosen per worktree from its context menu → Change marker…. In the wide rail the alias is shown next to the marker, so the marker is a scanning aid there; in the collapsed rail it is the row's only name, which is what Pixels is for: colour and shape at once. A collapsed row keeps the marker centred and larger, with the away arrow on its left and the node-health alert and the row-state glyph in fixed slots on its right, so the marker never moves however many of them are lit.
It also has browser panes: a tab in the same dock that renders one of the run's URLs, with a back/forward/reload row and an address bar. Each pane runs in a colour-coded session — its own cookie jar, named by you ("Admin", "Logged out", "Customer B") — so you can be logged in as two different users of your own app side by side and tell which is which from the tab's colour; hovering the tab's globe or the address bar's session dot names the session. Adding a session asks for its name (Enter keeps the suggested "Session 2"), and "Rename this session…" changes it later. Every new session is a brand-new cookie jar, never a reused one. Sessions are added and removed from the pane's session menu — up to eight alongside the default, remembered per worktree — and the same menu clears any session's data, signing out every pane on it. A pane that can't load tells you which problem it hit: nothing listening (start the run), a hostname that doesn't resolve, an untrusted certificate (veld self doctor), an expired one (different advice: Caddy renews these itself and veld restarts it when renewal stops, so the pane says to reload in a moment rather than to go installing a CA), a timeout, or a crash. A browser pane with no URL is the run's start page — "Where to?", then the places it can go, each row opening here on click with copy and open-externally beside it. The list is the run's own URLs and the worktree's recently edited files, and the project's ide.quicklinks are behind a Bookmarks button in that heading, which opens all of them in a dialog: with four to eight services per run, inline bookmarks pushed the addresses veld is actually serving into the middle of a screen that scrolled. Typing in the address bar still matches them, because a filter that could not see a bookmark would be a filter lying about its scope. The two kinds still say where they came from: the run's URLs carry a live dot and the service's name, a bookmark carries a bookmark glyph and no live dot, because veld started the first kind and only read the second out of a config — the glyph and the group carry that, not dimmed text, since a row that is perfectly clickable must not read as disabled. The pane does not take your keyboard when it opens: it is as often opened to click a URL as to type one.
Right-click inside the page for the menu a browser has there (Veld Desktop only — in a plain browser tab the pane is an iframe and your browser's own menu already covers it). On a link: Open Link in New Tab, which lands beside the pane on the same session, and Copy Link Address — or Copy Email Address on a mailto:, which copies [email protected] and not the whole mailto:…?subject= string, because getting an address into a To: field is the entire point of the gesture — unless the page hid something invisible in it (a newline, a right-to-left override, or a few thousand characters), in which case you get Copy Link Address instead and the raw mailto: string, where the smuggled character is still visibly escaped. On an image: the image itself, or its address — and on a video or an audio element, its address. On marked text: Copy. In a text field: undo, redo, cut, copy, paste and select all. On the page itself, where you aimed at nothing in particular: back, forward, reload and select all. Plus Inspect Element, which opens the pane's DevTools at what you clicked. Whatever was under the pointer decides the menu, so a menu raised on a link is about that link and carries nothing else — and a page with a context menu of its own (an editor, a spreadsheet) keeps it rather than being overruled.
The address bar is a search bar too. Typing something that is not an address searches for it (Settings → Browser panes → Search from the address bar, %s for the query, Google by default, empty to turn it off), which is what makes a pane usable for reading documentation and not only for previewing your own app. Focusing the bar lists those same places, filtered as you type, with ↑/↓ and Enter to pick one and the literal thing you typed as the first row — Go to or Search for, ringed as soon as it exists, because that is what Enter already does. Over a page that is already loaded the list is a dimmed overlay on the page rather than something that pushes it down: an embedded Chromium view paints over anything the app draws on top of it, so the pane freezes the page to a still, hides the view, and dims that — which is what an address bar looks like everywhere else. It used to sit in flow between the toolbar and the page, which reflowed your app on every keystroke. Anything that is not http(s) is still refused rather than searched for: a typo'd javascript: URL is a refusal with a reason, not a web search — but outside that closed set a colon is just a colon, so std::vec::Vec and a pasted error at line 12:5 are queries. A bare single label like grafana searches too, as it would in any browser; say http://grafana when you mean the host. A search is recorded in the pane like any other visited URL, so turn it off if you would rather a mistyped query left no trace.
A worktree opens on a single undecided pane — not a split. That chooser already lists the run's URLs, so opening split showed them twice and imposed two columns on every checkout the first time you looked at it, including the many that want one full-width terminal; splitting is a drag away, un-splitting something you never asked for is busywork. The top bar's globe opens a browser pane, and + in the tab strip opens an undecided pane (hover + for one-click shortcuts), or ⌘K ("Open <service> in a pane").
The undecided pane is grouped by what you are trying to do, in three groups whose order never changes between projects: work in a terminal (the project's own panes and a plain shell, together, because they are alternatives to each other — as equal-sized cards, each carrying its own description, since which agent you want is the repo's business and not veld's to recommend), open a page (the run's URLs as the list, with Bookmarks and Blank browser as two buttons pinned to the right of that heading — they are the two things that are not one of the run's URLs, and as extra rows they had pushed the answer into the middle of a scrolling list), and check the run (logs and node health, quieter, at the bottom). It got that shape from watching someone use it: they opened a Claude pane by pressing Terminal, because the declared pane was in a separate section further down, and they pressed Browser without ever connecting it to their app's URL five rows below. So the list of URLs is the browser affordance now, and there is no separate button competing with it. In Veld Desktop the pane is a real Chromium view, which means working history, page titles, isolated cookie jars, and pages that refuse to be framed still render. In a plain browser it falls back to an <iframe>: good enough for a preview, but a page sending X-Frame-Options shows blank, and history and separate sessions are unavailable — the pane says so, and offers to open the URL in your system browser instead.
An agent writes you a slide deck, or an HTML report after analysing something. Looking at it used to mean leaving Veld for Chrome. Now it opens in a pane, three ways:
open deck.html in a Veld terminal. The same shim that routes URLs routes a lone path to a file Veld can show. A text file inside the worktree — open notes.md, open data.csv — opens in the file pane instead of as raw text in a browser pane, once Veld is showing that kind at all: Plain text is off by default, so until you turn it on (or a pattern selects the file) open notes.md goes to the system opener as before. Anything else — a directory, a flag, two arguments, a file kind you have switched off — reaches the real open untouched and silently.veld ide open-url <file>, to invoke by hand or from a script.The file is served over http from an origin of its own — files.veld.localhost, a host that nothing else answers on — rather than handed to the pane as a file:// URL, and that is the difference between a deck that works and one that renders blank. A file:// document has an opaque origin, so Chromium refuses its ES module imports and its fetch of a sibling data.json; over http both work, and relative <link>, <script src> and images resolve the way the page's author expected. That origin also carries no management API, which is the point: the page is HTML something else generated, and on the daemon's own origin its scripts would be same-origin with every route under /api.
What may be opened is yours to set (Settings → Browser panes → Local files): web pages and PDFs are on, images and plain text are off, and a pattern list scopes by location (reports/*.xml) or opts one extension in without its whole group (*.log with plain text off). A pattern chooses among the kinds Veld can serve; it cannot add one, and it cannot reach into node_modules, target, dist, build or vendor, which the scan never descends into. The two that are off are off for the same reason: the list's value is that it is short, and a repository holds vastly more committed images and text files than generated documents — turn images on and a logo and a vendored diagram sit above the report that was just written. Secrets are never served whatever you write there: .git, .env*, *.pem, *.key and their neighbours are refused first, and only a closed list of extensions is served at all, so an unlisted file is a 404 rather than a download. Reads are confined to the worktree the pane belongs to, by a server-resolved grant rather than a path the page names — a symlink pointing out of the checkout is refused too.
A browser pane showing a file reloads itself when the file changes, which is the loop the feature exists for: the agent rewrites the deck and the pane shows the new one. Veld watches the file's timestamp; nothing is injected into the page, so what you present is what is on disk. What it watches is whichever file the pane is showing — not only the one it was opened on. Follow a relative link from one deck to the next and the watch moves with it, a set of decks that link to each other behaves like one document, and a #slide-3 fragment is still the same file. Following a link needs the desktop app, like history and sessions above: a cross-origin <iframe> in a plain browser tab reports no navigation, so there the pane keeps watching the file it was opened on. The pane's toolbar toggles it for that pane, and the default is a setting.
Each browser pane can also emulate a device. The presets are size classes, not model names — small/medium/large phone, three tablet sizes, a 14″ laptop, a 24″ monitor, a 27″ widescreen — because a list of named handsets is long, out of date within a year, and never answers the only question you were asking, which is how wide. Each class carries the metrics a current device of that class actually reports. Beside them is Responsive: it starts at whatever the pane can hold and you drag its edges, which is how you find the width your layout actually breaks at. The page reflows while you drag — the screen grows from its centre, and the size under your pointer reads out in the toolbar. Any device can be dragged, not just that one — a phone dragged narrower keeps its touch events and user agent and becomes a custom size. Plus rotate, a mobile-user-agent toggle, an explicit custom size, touch events and safe-area insets, so a swipe gesture, a @media (hover: none) rule or a header pinned to env(safe-area-inset-top) is testable without leaving the app. The useful half is the large sizes: a pane is small and a desktop layout is not, so a 1440-wide viewport is rendered at 1440 and scaled to fit the pane, which is a view no browser window can give you without a second monitor. Page zoom sits in the same control, with a reset. Both are remembered per pane and survive a reload, a session switch and a worktree switch. DevTools opens per pane in its own window (the ⟨⟩ button) — detached, because a docked inspector and an embedded view fight over the same box. Safe-area insets come with the handheld presets: an emulated phone reserves the notch and home-indicator gutters a real one does, and the page reads them through env(safe-area-inset-*) — so a layout that pins a header or a bottom bar to the safe area can be checked in the pane instead of on a handset. The phone classes reserve 47/59/62 at the top and 34 at the bottom; held sideways the sensor housing appears on both sides at once and the home indicator shortens to 21, which is what a real handset reports and is not any rotation of the portrait numbers, so each class carries both sets rather than a transform. Rotate, or drag or type a size that crosses square, and the gutters follow the shape — a landscape viewport with a notch at the top is a state no handset can be in. Tablets reserve a home indicator and nothing else, identically either way up. The screen presets reserve nothing at all — a monitor claiming a notch would make every one of them a worse desktop than the pane it replaced. A menu item turns them off and on per pane; on a screen preset it is inert and says why, because there is nothing there to reserve, while a custom or responsive size, having no class to inherit from, gets the phone set. Dragging a preset's edge makes it a custom size, so gutters at a desktop width are one drag away rather than unavailable. is set to the same numbers, because an emulated viewport has no retracting browser UI and its inset is therefore already its own maximum. , and that is not a choice: Chromium defines all four variables and reports for them with no override at all, so a page written as never sees its fallback whether or not this is on. Touch emulation needs Chromium's debugging channel, which something else can hold, so the pane reports whether touch is actually in force rather than assuming — its device menu says so when it isn't. The safe-area insets and the emulated media features ride that same channel and are reported the same way, and it is the insets that need it most: touch that has been taken away shows up the moment you drag, while gutters quietly reading look exactly like a page with no notch to clear. The mobile user agent is the only: and the headers still report your desktop, so an app that branches on client hints rather than the UA string keeps serving its desktop bundle. The menu says that too. A device only takes effect once the pane has a page: an empty pane has no view to emulate against, so the device you pick applies to the first thing it opens. In a plain browser the work (the frame really is that many CSS pixels wide, so your media queries respond), but the user agent, touch, safe-area insets, device pixel ratio, page zoom and DevTools all need the desktop app — the menu says so rather than pretending otherwise.
A pane can also emulate the page's media features — prefers-color-scheme, prefers-reduced-motion and forced-colors — from the same device menu. It is the same question a device width asks, put to a preference: what does this look like for someone whose OS says dark, or who has asked for less motion? There is no reload; a media query is live, so the page re-evaluates in place. The control is worded as being about the page, because Veld themes itself light and dark too. These ride the same debugging channel touch does, so the pane reports whether they are actually in force, and the browser build says so rather than offering a control that would do nothing.
The two you reach for constantly get one-click switches in the pane's toolbar, either side of the device button: a phone, and the page's colour scheme. Both are shortcuts into the device menu, which keeps every one of these controls — turning a switch off costs reach, never capability, which is why which switches appear is a setting (Settings → Browser panes) rather than two more buttons for everyone. It is a standing choice about whether you want the shortcut at all, not a per-pane one: the setting is global, so off is off in every pane and window. The phone switch applies the Phone preset (402 × 874), touch, safe-area insets and mobile user agent included, and its edges are draggable from there — so the resizable viewport is one drag away rather than being what the click spends itself on. Its off is no emulation at all, so it answers one question ("am I on the phone") rather than restoring a device you picked earlier. The colour scheme cycles System → Dark → Light → System, so a light-only layout bug is one click away too, and System is the absence of an override rather than a third value — which is also what releases the debugging channel; it reports what the pane achieved, showing as paused rather than set while something else holds Chromium's debugger. In a plain browser the phone switch's size works and the colour-scheme one is shown inert, saying why in its tooltip and its accessible name.
Permissions in a browser pane used to be refused across the board, for a defensible reason: a prompt raised inside an embedded pane has no chrome to attribute it to, and "example.com wants your camera" is a lie when the window says Veld. Now the prompt is in the pane, where it can name the site, the pane and its session colour — and where blocking it costs nothing but the page's request. It sits above the page rather than over it, so it never hides what asked. Answers are remembered per session and origin the way a browser remembers them, and the shield beside the address bar opens that site's settings: every permission, each Allow / Ask / Block, and where the current answer came from.
A project can also pre-answer permissions for its own origins, in veld.json — so a dev server that needs geolocation or a camera works for everyone who clones the repo instead of prompting each of them one at a time:
"ide": {
"permissions": [
{ "origin": "http://localhost:*", "allow": ["geolocation", "clipboard-read"] }
],
"quicklinks": [
{ "label": "Staging", "url": "https://staging.example.com" }
]
}
A grant you make by hand always beats the file, in both directions, and anything from the file is labelled set by veld.json in the site panel where you can revoke it — so this is a default, not a decision taken out of your hands. Understand what you are declaring, though: an entry for a remote origin hands that server's JavaScript a standing capability on the machine of anyone who opens it in a pane, which is a step beyond what the rest of a config does. See docs/configuration.md for the origin syntax, the full id list and the defaults.
One permission is answered without asking anyone: a pane may capture its own contents at an origin veld serves.
ide.externalOrigins is the third key in that block: origins a URL from a terminal must open in the system browser rather than in a pane — the sign-ins your app's login goes through, which need the browser the developer is already logged into. It is unioned with each user's own exempt list rather than replacing it.
ide.quicklinks is the other half of the same block, and the other half of a pane's start page: veld lists the URLs it made, and these are the ones it didn't — staging, a dashboard, an internal wiki — versioned with the repo so a teammate who clones it gets the same links. They sit behind that page's Bookmarks button, and the address bar matches them as you type.
A project can tell its own team something changed (ide.news). Somebody moves the test command or adds a required env var, and the people it affects normally find out when it breaks for them. A news entry is a short card — an eyebrow, a headline, one sentence and a date — merged alongside the change it describes; a teammate pulls, and the next time they open that project in the IDE they are told, once. It rides the same channel veld uses for its own announcements, so reading clears it, dismissing keeps it counted, and everything stays revisitable under What's new… in the project ⋯ menu. Cards carry the project's name where veld's carry the wordmark, so a teammate's sentence can never read as something veld said.
The limits are the point rather than an implementation detail: a headline of 44 characters, a body of 160, and five live items at most — with retiring one meaning deleting it. A surface that interrupts everybody on the team is worth having only while opening it is reliably worth their attention, which is also why the honest advice is to write the outcome ("stop guessing which test script works") and not the change ("test wrappers removed"). Anything malformed, or past the cap, is a veld orchestrator lint warning and never a load failure; only news in your main checkout counts by default — the primary clone, at whatever it has checked out — so a card drafted in a worktree cannot prompt anybody until it lands. Settings → General → Read project news from (news.source) can switch that to every checked-out worktree instead, for previewing a card you're writing before it merges. A reader who wants none of it turns off Settings → General → Show news from your projects. See docs/promotions.md.
"ide": {
"news": [
{
"id": "one-command-tests",
"since": "2026-08-12",
"eyebrow": "Heads up",
"headline": "Stop guessing which test script works",
"body": "The wrappers are gone — `just test` runs everything, and your old local alias is the one thing that will still fail today.",
"glyph": "terminal"
}
]
}
ide.extensions is what the project puts in the top bar: a status badge, a button, or a menu grouping several buttons. A badge is a command veld runs in the worktree whose stdout it renders — { "text": "PR #284 · checks green", "tone": "success", "href": "…" } — and a command that knows nothing about veld already works, because output that is not the contract becomes the badge's text (git rev-parse --short HEAD is a one-line badge). Exiting 0 with nothing to say hides the badge; failing renders it red with the tool's own message on hover. A badge's output may also offer actions, and those name action entries you declared by id — veld resolves each against your config before offering it, so the running command chooses among your commands and can never introduce one. Links open in your own browser by default, not a pane, because a pull request is a page you are already signed in to. Which side of the bar an entry sits on is align; a missing tool is hide, disable, or (the default) a dashed hint with the install page one click away. An action can instead carry accepts: "file" and no slot, which offers it on a file path clicked in terminal output and hands it the path as $1 and the line as $2. Declarations come from your project's main checkout by default — Settings → General → Read a project's extensions from (extensions.source) — so a worktree cloned before your veld.json gained ide.extensions still sees them; commands still run in the worktree you're looking at. Switch it to This worktree to test a new badge in the branch that's writing it, before merging. See docs/configuration.md for the field reference and what bounds these commands.
A project can add its own panes (ide.panes), which is how the dock stops being a fixed set of four kinds. A declared pane is a terminal that runs your command inside your login shell — the same shell a plain terminal opens, so it inherits everything .zprofile/.zshrc export (model tokens, tool paths) — and it appears in the + menu, the pane chooser and ⌘K alongside veld's own. The chooser shows them as equal cards in declaration order, beside veld's plain Terminal, each with its description under its label — nothing is promoted, because a repo that declares Claude, Pi, Codex and a git log has four things a contributor might want and picking one for them would be a guess dressed as a default. agent marks a pane as a coding agent — which is what the New worktree… dialog offers a prompt to. Leave it out and veld infers it from resume: a pane that can pick its session back up is one holding a conversation, and one that cannot (that git log) is one where a typed prompt would exit with the pane or be run as a command. Set it only where that is wrong — true for an agent with no resume flag, false to keep a resumable pane out of the picker.
"ide": {
"panes": [
{
"id": "claude",
"type": "terminal",
"label": "Claude",
"icon": "sparkles",
"requires_bin": ["claude"],
"argv": ["claude", "--session-id", "${veld.pane.token}"],
"resume": { "argv": ["claude", "--resume", "${veld.pane.token}"] },
"auto_resume": true
}
]
}
Nothing there is a vendor veld knows about. It knows how to run a command in a terminal, how to check that claude is on your PATH before offering the pane, and how to hand the command a stable identity — ${veld.pane.token}, a UUID it mints per pane and remembers in its own database. Which tool that is, and what its resume flag is called, lives in your repo.
A tool that won't accept an id you chose needs no token at all. codex mints its own, so its pane is just "argv": ["codex"] with "resume": { "argv": ["codex", "resume", "--last"] } — same Resume button, same auto_resume, and codex's own resume is scoped to the working directory, so "most recent" means most recent in that worktree. What the token buys, and this shape gives up, is per-pane identity: two Codex panes in one worktree resume the same session, where two Claude panes hold two separate conversations. This repo's own veld.json carries both, precisely so the difference is visible.
That token is what lets a pane fake surviving a reboot. Veld's terminals already outlive a daemon restart and a veld self update, because each shell sits in its own holder process — but nothing carries a process across a reboot. The conversation can be carried, though: a coding agent keeps its own transcript keyed by the session id it was given, so re-launching with the same id brings it back. A fresh launch always mints a new token, so "start fresh" really is a new conversation, and two Claude panes in one worktree hold two separate ones — which is precisely what a bare --continue cannot do.
sessions is the other question a pane can answer: resume a session it never started. resume reopens what this pane launched, which is nothing at all the first time you open it in a worktree, and nothing for any conversation from before the pane existed. A pane can name a command that lists what its tool already has on disk:
"sessions": { "label": "Resume an earlier Claude session",
"argv": ["scripts/veld/claude-sessions.sh"] }
// ...or "ask_first": false to keep click-to-launch and put the list on the card
Clicking the pane then asks which session you want, with Start fresh as the first row and what the script found underneath. Pick a row and the pane's own resume command runs, with ${veld.pane.token} set to the value you picked. So a script chooses which session a declared command opens and can never contribute a command — which is why the resumed conversation comes back with every flag the pane's label promises. Asking is the default (ask_first) and it is the default on purpose: a picker behind a small control is one most people never find, and "you already have a conversation about this worktree" is exactly what somebody needs told before they start a second one. It only ever asks when there is something to ask about — a script that found nothing opens the pane exactly as it always did — and Start fresh is always the first row, so it is one extra click and never a dead end. Set ask_first: false and the click launches instead, with the list in the card's other half. Stdout is one row per line: a value, then optionally a tab and the text to show, then optionally a tab and a quieter detail, so a pipeline ending in basename is a working picker with no adapter. Exit 0 with no output means there are none, and nothing is offered — the answer a fresh clone gives, and not an error; a non-zero exit still opens the dialog and shows the last line of stderr, because a broken script nobody can see is a broken script that stays broken. It needs a resume that references the token, so the codex resume --last shape above cannot have one and veld orchestrator lint says so rather than opening the same session whatever you chose. This is the one thing in ide.panes that runs a command before you click a pane — once, while the chooser is on screen, never on a timer and never from the + menu — so it is under the same bounds and the same off switch as ide.extensions. And it is worth checking whether your tool has a picker already: claude --resume with no argument and codex resume both open their own, and a plain pane running one of those is less to maintain.
auto_resume is deliberately narrow. It fires only when a pane comes into being with its shell already gone — the app starting up and restoring your layout — and never while you are watching: a tool that exits in front of you always waits for a click, whatever the config says. It defaults to false, because these commands launch coding agents and an unattended one spends money and runs tools with nobody there. For the same reason a resume that fails is never quietly retried as a fresh launch; the pane says so and offers Start fresh as its own button.
close_on_exit is the other half, and defaults to true: quit the tool and the pane closes, the way a terminal emulator does. Two things bound it. A non-zero exit never closes the pane — the reason a tool died is printed on the screen it dies on, and a pane that disappears takes the error with it. And it only fires on an exit somebody was there to see, so it never competes with auto_resume: a reboot, a quit app or a reaped session leave the pane to be restored from the layout instead. When a pane is sitting there not running, it says so across the whole pane — icon, label, what happened, Resume and Start fresh as real buttons — with a Show output link back to whatever is underneath, because by then the pane has usually failed and that output is the reason.
fixed_label is the third, and the only one whose default says let the tool decide. A declared pane's tab adopts whatever terminal title (OSC 0/2) the process inside it sets, exactly as a plain terminal's does — for a coding agent that title names the task it is on, and a rail of four tabs all reading "Claude" is the thing it fixes. Set "fixed_label": true on a pane whose label is the landmark instead: Claude (skip permissions) has to stay legible as that pane, and a live task title would take the answer away. The pane's identity never moves either way — ${veld.pane.id} and ${veld.pane.label} are what the config says, notifications are named by the pane rather than by its current title, and the tab is back on its label after a reload.
Restart is the same pair of choices, offered without waiting for the tool to exit. Right-click a pane's tab: a plain terminal offers Restart this terminal, and a pane the daemon still holds a token for offers Restart and resume — hang up what is running now, then run the pane's resume command, so the tool comes back on the same conversation having re-read whatever it loads at startup. That is the point of it: change a coding agent's settings and apply them to the pane you have been working in, without starting over. Restart from scratch sits underneath for when starting over is what you meant, and mints a new token like any fresh launch. Both ask first whenever something is actually running — a restart hangs up an agent mid-turn the same way closing the pane would — and Restart from scratch asks even on a pane that has already exited, because abandoning a conversation the token still points at is a loss you cannot see on the screen. The card's own Start fresh asks the same question for the same reason, so the two surfaces cannot disagree about it. The scrollback stays as history either way, and while the pane comes back it says so across the whole pane rather than in a corner. ⌘K's Restart this terminal is the same action, with the same question.
The dock also holds the run's diagnostics, so a worktree that is misbehaving can be diagnosed without leaving it. A Logs pane is the same viewer the dashboard has — search with ±N context lines, node filter, source filter (server/setup/internal), a run picker over history and auto-scroll, and colour: a line's ANSI colours are rendered rather than printed as escape codes, other escape sequences are dropped, a progress line that overwrites itself shows its last state instead of every frame at once, and search matches the text you can see rather than the bytes underneath it — and a Nodes pane is the per-node health table: status, failure/recovery counts and the last liveness error, URL with copy/open, variant, PID, live CPU and memory with a sparkline that expands into the scrubbable resource chart, and each node's configured actions. In IDE mode each node's URL also carries an open-in-a-pane button, so a service opens in an embedded browser beside the terminal instead of in another application (copy and open-in-your-browser sit next to it). The same node actions are also one click up in the top bar (a menu beside the run controls) and in the new-pane chooser, so a running project's actions are not behind a pane you have to open. They are literally the dashboard's two views, not lookalikes: runs mode is a run's controls plus a Nodes|Logs switcher over the same components, so the two surfaces cannot drift. Both read whichever run the selected worktree has, so switching worktrees re-points every open diagnostics pane; both reach past runs (the logs pane's run picker, a picker in the nodes pane's header); and both keep working after a run ends — a crashed run's logs and last node states are exactly what you want then. The nodes view is a card per node rather than a table — a table has columns to lose, and this view has to be readable in a 300px pane and in a 1080px dashboard card alike; here nothing is dropped as the width changes, only rewrapped. Open them from + in the tab strip, the pane chooser, or ⌘K.
The dock also holds the run's diagnostics, so a worktree that is misbehaving can be diagnosed without leaving it. A Logs pane is the same viewer the dashboard has — search with ±N context lines, node filter, source filter (server/setup/internal), a run picker over history and auto-scroll, and colour: a line's ANSI colours are rendered rather than printed as escape codes, other escape sequences are dropped, a progress line that overwrites itself shows its last state instead of every frame at once, and search matches the text you can see rather than the bytes underneath it — and a Nodes pane is the per-node health table: status, failure/recovery counts and the last liveness error, URL with copy/open, variant, PID, live CPU and memory with a sparkline that expands into the scrubbable resource chart, and each node's configured actions. In IDE mode each node's URL also carries an button, so a service opens in an embedded browser beside the terminal instead of in another application (copy and open-in-your-browser sit next to it). They are literally the dashboard's two views, not lookalikes: runs mode is a run's controls plus a Nodes|Logs switcher over the same components, so the two surfaces cannot drift. Both read whichever run the worktree has, so switching worktrees re-points every open diagnostics pane; both reach past runs (the logs pane's run picker, a picker in the nodes pane's header); and both keep working after a run ends — a crashed run's logs and last node states are exactly what you want then. The nodes view is a card per node rather than a table — a table has columns to lose, and this view has to be readable in a 300px pane and in a 1080px dashboard card alike; here nothing is dropped as the width changes, only rewrapped. Open them from in the tab strip, the pane chooser, or ⌘K — or from the rail itself: a worktree whose run has , or one whose nodes veld is , carries a warning on its row that says which, and clicking it brings you to that worktree with its Nodes pane in front. The rest of the row's run state is on its start/stop control, which spins while a run is coming up or going down — including a run you started from the terminal or from another window, not only one you clicked here.
Sharing is one surface in the top bar: it starts and stops the peer share and the public --web share for the selected worktree's run, offers the join link and the veld network join command, toggles auto-accept, and shows each live connection's transport (direct, or relayed via <relay>, with RTT) — so a slow share is diagnosable here too. Join requests are not hidden behind it: while someone is waiting for approval, a prompt sits above the panes naming who wants to join which run, with Approve and Deny. Sharing is opt-in per port (share.expose) and needs a relay (sharing.relays), so a run that has neither is refused rather than shared — the daemon says exactly what to add to veld.json, and both UIs now show that text instead of a bare status code.
Because a terminal is a shell on your machine, /api/pty/attach is gated more tightly than the rest of the daemon's API: WebSocket handshakes cannot carry the X-Veld-Request CSRF header, so an attach needs a single-use ticket minted through a CSRF-gated POST and an Origin on the allowlist, failing closed when Origin is absent. Details and the reasoning are in crates/veld-daemon/src/pty.rs.
An agent writes a plan, a CSV, a function you need to look at. The file pane is where you read it without leaving the worktree: a read-only tab in the same dock, beside the terminal that produced it. Nothing in Veld writes files — this is for reading, and your editor is still where you edit.
.md opens in the file pane. Raw HTML in the markdown is not rendered — the pane lives on the IDE's own origin, and a document an agent wrote does not get to put markup there — and remote images are not loaded in this version; the alt text shows instead.files.showDeletions, off by default) turns those markers into a unified diff of the whole file — deleted lines as red rows between the current ones — measured from the same base; line numbers and references still name the file as it is now. Side by side beside it (files.splitDiff, off by default) lays the same diff out in two columns, the base on the left — pressed from the plain view it turns the diff on too; a reference cites the right-hand, current lines. Wrap long lines (files.wrapLines, on by default) wraps instead of scrolling sideways. All three header switches are remembered as settings.docs/plan.md:12-18 followed by the excerpt as > lines — the shape to paste back to an agent. The path is worktree-relative inside the worktree and absolute outside it. Copy file contents beside it copies the whole file's text, with no path or quoting.Four ways in: veld ide open <path> from an agent or a script (above); clicking a path in terminal output (View in Veld); open notes.md in a Veld terminal, for a text file inside the worktree when Plain text (files.viewPlainText, off by default) or a pattern lets Veld show it; and Changed files, one click from a new pane — every file this branch changed against the merge-base with the default branch, plus uncommitted and untracked ones, each opening in a file pane. It is the cheap review: what moved, and the gutter shows where.
Files outside the worktree can be shown, but not any file. A file pane reads inside its own worktree; inside a folder you list in Settings → Browser panes → Local files → Folders a file pane may also open (files.extraFolders, absolute or ~/ paths — a notes folder, a scratch directory); and an exact file you opened from that worktree's UI or that veld ide open named there, which Veld remembers per worktree. Secrets — .env*, keys, .ssh and their neighbours — are refused everywhere, outside the worktree included, and so is a binary file or one over about 5 MB, with a message in the pane rather than a blank one. Outside the worktree the places CLIs keep logins are refused too, whatever let the file in: ~/.ssh, ~/.gnupg, ~/.aws, ~/.kube, ~/.docker, ~/.codex, ~/.config/gh, ~/.config/gcloud, .netrc, .npmrc, .yarnrc.yml, .pypirc, .git-credentials, and any file named like *credential*, auth.json, hosts.yml, token or tokens.json. ~/.claude is split rather than refused, because Claude Code writes its plans and memories there: only Markdown under ~/.claude/plans/ and ~/.claude/projects/ opens. That is a deny list, not a guarantee, so list in files.extraFolders only folders you would show anyone; /, your home folder and any folder above it are refused outright.
The same /ide UI as a desktop app: a native window with a menu-bar icon, real Chromium browser panes (working history, page titles, isolated cookie jars, and pages that refuse to be framed), device emulation, a right-click menu inside the page, and per-pane DevTools. Everything else works identically in a browser — the app is a shell around the daemon, not a second implementation.
The menu-bar icon is optional. It is where the app says what is running with no window on screen — the live runs, Open Veld Desktop, New Window, the version and Check for Updates… — and it is on by default for that reason. Turn it off in Settings → General ("Show the menu bar icon"), or with veld self settings set desktop.menuBarIcon false, and it leaves the menu bar at once; the app itself stays where it was, reachable from its Dock icon, so what you lose is the ambient status rather than the way in. Like every other preference this is the daemon's, not the app's, so a browser tab can set it too — the app is told, and reads it again on its own ten-second tick for a change made while it had no window open.
Windows. ⌘N opens another full window — one worktree per monitor, rather than switching back and forth in one. Right-click a worktree in the rail for Open in a new window to send it straight there, or use the project selector's Open in a new window to give a whole project its own window — that one names no worktree, so the new window takes whichever of the project's checkouts is free rather than pulling one away from wherever it already is. ⌘K has the same entry per project, and so does a right-click in the project column. In a plain browser the same entries say Open in a new tab and open one, which the daemon arbitrates exactly like a second window.
A worktree has one set of panes, and one client shows them. Windows are for working on different worktrees side by side, not for opening the same one twice: pick a worktree another window already has and Veld brings you to that window instead of growing a second set of terminals. Those rows are marked in the rail before you click one, and the switch says so, so a window coming forward is never a surprise. Close that window and the worktree is free again — open it anywhere and its panes come back, still attached to the same shells, because the layout belongs to the worktree rather than to the window that happened to show it.
And "anywhere" includes your own browser. The panes live in the daemon, so /ide in Safari or Chrome is the same set as Veld Desktop's — the same tabs, the same splits, and the same running terminals, which it re-attaches to rather than starting a second copy of. Ownership is the daemon's too, so a browser tab takes part in it like any window: a worktree open in a tab is marked in the desktop app's rail, and clicking it says where it is. The one asymmetry is honest rather than hidden — a browser cannot raise its own tab, so Veld tells you which browser to switch to and marks that tab's title, where a desktop window it simply brings to the front.
A pane can also leave its window: right-click a tab → Open in a new window, or drag it out and drop it — on your second screen, and that is where the window appears. A dropped pane can go back the same way, or into any other Veld window showing that worktree: drag it over and the target shows its own drop indicators, so it lands at the tab position or pane edge you aimed at rather than at the end. That bare dock window can be split and hold tabs of its own, and closing it returns its tabs rather than ending them — closing a pane is still what ends a shell. Detaching a browser pane reloads its page (a Chromium view belongs to one window, so moving it is a rebuild); the URL, session, device and zoom all survive. Every window comes back on the next launch with its panes attached to the same shells. Up to eight windows.
Updates come at the pace you pick. The app checks for new releases in the background and asks before installing one, and how often it asks is desktop.updateFrequency in Settings → General ("How eagerly to offer updates"). The default, Once it has settled, waits until a release is a day and a half old — the fix for a bad release lands within hours, so waiting skips the dialog for both — or until the app has seen four pile up, and it never asks more than once a day. Every release checks hourly and offers whatever it finds; Rarely gives it three days to settle and two days between prompts. Whatever you pick, Check for Updates… answers immediately and spends none of that budget; once you've answered about a release it stays quiet for a week, rather than coming back at the next launch; and veld self update in a terminal is unaffected — this setting is about the app asking you, not about the only way to get a release.
It needs the veld CLI, which it does not ship. On a machine that has never had veld the app shows the two commands that get you there — the installer and veld self setup unprivileged — and waits for the daemon to appear. On a machine that does have veld it says something different, which matters most right after an update: the app can reopen before the daemon has finished restarting, so for the first minute it simply says it is starting up. Only after that does it offer veld self setup unprivileged and veld self doctor — never the installer, which would have you re-run an install over a machine that already has one.
The app is optional, and the installer asks once. Plenty of people use veld purely as an orchestrator and never open the IDE; making them download a ~113 MB app on every veld self update was wrong. So on macOS the installer puts the question, records the answer in ~/.veld/desktop.json, and every install and update after that obeys it — veld self update moves both halves for someone who wants the app and skips it entirely for someone who doesn't.
Change your mind at any time: veld self desktop install adds it and records yes, veld self desktop uninstall removes it and records no. That second one works on a machine with no app installed too, which is how an orchestrator-only box says "never fetch this" up front. veld self desktop status and veld self doctor both show the recorded answer, so an app that is deliberately stale reads as deliberate rather than broken.
If you have been running veld for a while, you have the app and were never asked — every release before this one installed it along with the CLI. The next interactive veld self update (or curl … | bash) says so and asks whether to keep it: yes keeps it up to date exactly as today, no removes it there and then and stops veld fetching it again. It is asked once.
A run with nobody to ask — an agent, CI, the app's own Check for Updates… — never puts up a prompt and never guesses in the expensive direction: an app that is already installed is kept in step, and one that has never been installed is not downloaded. VELD_DESKTOP=0 still skips the app for a single run and deliberately records nothing, because an environment variable is a statement about one command rather than a preference you can be asked about.
Release builds are signed and notarized, so a browser download opens too; installing through veld additionally sets no com.apple.quarantine flag at all. veld self desktop status says what is installed and whether it matches the CLI.
The app's own Check for Updates… hands the job to the CLI, and hands it the whole release: the dialog offers veld 16.8.0, not a new app, and Quit and Update veld runs veld self update — CLI, daemon, helper and app, from one tag, in one restart. An app cannot replace its own bundle while running, so it quits, the CLI does the work, and the app reopens on the new version. This is why there is no second trip to a terminal afterwards, and no "your CLI is behind" notice a minute later: both halves move together or neither does.
And it opens a terminal window to do it. The app quits, so for the next one to four minutes there is no Veld window to draw a progress bar in — and, less obviously, no controlling terminal, which is what sudo needs to ask for a password on the one path that still requires one (a /usr/local install, below — the helper restart no longer does). Without one the update only ever gets sudo -n and gives up silently. So the CLI re-runs itself in your terminal: on macOS by opening the generated ~/.veld/update-console.command through LaunchServices, which honours whatever you have registered for .command files (Terminal.app unless you chose otherwise); on Linux through $TERMINAL, x-terminal-emulator, or the first emulator it finds. You watch the whole install, and a password prompt appears where you are looking. A successful update closes its window when it is done (Terminal.app, which keeps finished windows open by default, is told to close it); a failed one stays open with the error until you press Return. If no terminal can be opened — a headless box, a machine with no emulator installed — the update runs exactly as it did before, in the background with its output in ~/.veld/desktop-update.log, rather than failing. The app does not take "a launcher started" for an answer: it waits for the window's veld self update to actually claim the update lock before it believes a window exists.
Only one update ever runs at a time. veld self update takes a lock at ~/.veld/update.lock and publishes what it is doing into it; a second one refuses with exit code 75 (EX_TEMPFAIL) and tells you who is updating, since when, and which phase they are on. So do the rest of veld's commands — anything that touches the daemon, the helper or the installed binaries, since those are being replaced; veld self doctor, veld self version, veld orchestrator config, veld orchestrator lint, veld orchestrator init and veld self desktop status keep working — and so does Veld Desktop, which quits with an explanation if you open it while its own bundle is being swapped. A lock never becomes permanent: it is written off the moment its holder's process is gone, or after 30 minutes without progress (the case where somebody walked away from a sudo prompt), and veld self update --force skips the wait. veld self update --status and veld self doctor both read the same file, so all three agree.
An update never asks for your password. In privileged mode the helper is a
root service, so an unprivileged veld self update cannot bounce it with launchctl
— but it does not have to. The helper restarts itself: the CLI asks it to over
the same Unix socket it already exposes, and it exits leaving Caddy running, so
launchd relaunches it on the new binary and no live URL blinks. A helper too old
to understand the request falls back to its own binary watcher, which notices the
file changed and does the same thing within about twelve seconds. Sudo is offered
only after both of those have had their full budget and the helper still has not
come back — a prompt that arrives with a reason on the line above it, rather than
one that interrupts a working update. veld self setup privileged's "sudo once, you
won't be asked again" is meant literally.
It needs a CLI that advertises it can do it — veld self desktop status --json reports a capabilities list, and full-update-handoff is the one that matters. Two things withhold it: a CLI too old to have the flags, and a CLI installed under /usr/local. The second is not a version problem: install.sh refuses to relocate a system install (a privileged LaunchDaemon still points at /usr/local paths), so the install itself needs sudo — not just the helper restart. The terminal window above would give sudo somewhere to ask, but it is a best-effort thing: when no terminal can be opened the update falls back to running headless, and on these machines that fallback cannot finish rather than merely finishing slowly. A capability the binary can only sometimes deliver is worse than one it withholds, so it stays withheld: those machines get the app-only veld self desktop update — exactly what they got before — and the dialog says run veld self update afterwards to move the rest of the release. From a terminal, where sudo can prompt, veld self update moves both halves for them normally. Against a CLI with no veld self desktop at all the app points you at the release page rather than quitting into a command that does not exist. Because nothing is watching a terminal while the app is gone, everything the update says lands in ~/.veld/desktop-update.log, and the app tells you on the way back if it did not work rather than reopening on the old version in silence. On Linux the AppImage still updates itself and a .deb still belongs to your package manager.
From a terminal it works the other way round. veld self update with the app open used to print an error and skip it — the bundle cannot be swapped under a running app — so it now asks first:
ℹ Veld Desktop is running, and its bundle cannot be replaced while it is.
Nothing is lost: terminal sessions belong to the daemon, keep running while the
app is closed, and reattach with their scrollback when it reopens.
Close Veld Desktop, update both halves, and reopen it? [Y/n]
Yes closes it politely — an Apple Event first, so before-quit persists your window layout exactly as ⌘Q would, falling back to SIGTERM, never SIGKILL — updates everything, and reopens it. Say no and only the CLI half moves. An app showing an unanswered dialog is left alone rather than forced. A non-interactive run (no TTY, or VELD_NON_INTERACTIVE) never closes your app without being asked: it skips the app half and says so, which is what keeps a scripted or agent-driven veld self update from taking a window off your screen.
You can also download the .dmg (macOS) or .AppImage / .deb (Linux x64) from the latest release — checksums.txt on the same page has a SHA-256 for every artifact. The app ships with every veld release and carries the same version number as the CLI — one tag, one version, so the app and the daemon it talks to are halves of the same thing. When they drift apart the app says so and names the fix.
macOS: signed and notarized. Every macOS binary in a release (
veld,veld-daemon,veld-helper,caddy,veld-gateway) carries the team's Developer ID, and the app and its.dmgare notarized too, so a downloaded.dmgopens without a Gatekeeper detour. (The CLI binaries are not notarized. The installer fetches them withcurl, which Gatekeeper does not check. Aveld-gatewaytarball you download in a browser needsxattr -d com.apple.quarantine veld-gatewayonce.) The signature is also what lets macOS recognise an update as the same program: permissions you granted (Full Disk Access, Login Items) survive every update. The one exception is the first signed release after an older, ad-hoc-signed one — macOS sees a new program once, so re-enable veld's entries in System Settings → Privacy & Security and General → Login Items if it asks. Maintainers: docs/macos-signing.md.
Share a running environment with a colleague so they open the same URLs on their own machine, over an encrypted peer-to-peer tunnel (iroh: QUIC with NAT hole-punching and an n0 relay fallback). No accounts, no Veld-hosted server.
Ports must opt in, one at a time. share is a field on a port — that is where exposure happens — and veld network share refuses to expose anything that hasn't declared one. This makes what leaves your machine explicit and auditable:
{
"sharing": { "relays": "public" },
"nodes": {
"api": {
"variants": {
"dev": {
"type": "long_running",
"shell": "api --port ${veld.port} --admin ${veld.ports.admin}",
"ports": {
"http": { "port": "auto", "protocol": "http", "share": { "expose": ["peer"] } },
"admin": { "port": "auto", "protocol": "http" }, // ops console: never shared
"postgres": { "port": 5432, "protocol": "tcp", "share": { "expose": ["peer"] } }
},
"probes": { "readiness": { "type": "port" } }
}
}
},
"frontend": {
"variants": {
"local": { "type": "long_running", "argv": ["npm", "run", "dev"], "share": { "expose": ["peer"] } }
}
}
}
}
frontend shows the shorthand: a share written on a node or a variant is defined as the primary port's policy. That keeps every config written before per-port consent meaning exactly what it meant — such a node had one exposed port — while making it impossible for the same three words to start covering an ops console or a database. It lands on the primary and stops there. A port's own share replaces the shorthand for that port rather than merging with it, an absent share is always "not shared", and nothing anywhere widens a port that declared none. veld network share names every port it excluded and why, so a partial share is never silent.
veld network share my-feature # prints a join URL to send (plus a veld network join command)
veld network share prints a join URL as the primary way to share: https://veld.localhost/join#<ticket> (or :18443 in unprivileged mode). Send it to a colleague — they open it in their browser, which loads their own Veld dashboard, connects, waits for your approval, then shows the shared URLs as clickable links. The veld network join <ticket> command is an alternative for a terminal-only join, and --json output includes a join_url field. The ticket is short and constant-size no matter how many URLs the run exposes — the URL manifest is sent over the tunnel after approval, not embedded in the ticket.
While a share is live, veld keeps this machine from dozing off — a share nobody's laptop is awake to serve is a dead link, and the machine suspending is not something a share can do anything about. It is on by default, capped, and asked separately for mains and battery; veld network share prints an Awake: line saying which you got. The full rules, including the one thing it will never do without being asked, are under Management UI above.
You can also drive sharing from the dashboard: each running run's card has a Share button (which also copies the join link to your clipboard); once shared it exposes Copy link / Copy command buttons, a live joiner count, an auto-accept toggle, and Stop sharing. Pending join requests (Approve/Deny) and joined shares appear in a panel.
Both people must have Veld installed and be in the same setup mode — both privileged (clean URLs) or both unprivileged (:18443 in URLs) — so the URLs match. The consumer's own Caddy issues a locally-trusted cert, so there's no cert warning.
Two gates protect a share: a capability token embedded in the ticket, plus host approval. Approval modes (--approve):
manual (default for interactive use) — you approve each join via the dashboard (which opens automatically) or veld network approve <REQ_ID>first (default with --json) — auto-approves and pins the first token-valid joiner, rejecting the restauto — approves any token-valid joinerTraffic is end-to-end encrypted between the two velds; a relay only forwards sealed bytes and never sees your URLs or content. Relay selection is a config-level compliance control and must be opted into explicitly — there is no implicit default, so nothing is ever routed over n0's public relays by accident. Set sharing.relays to "public" (n0's public relays) or to an array of self-hosted relay URLs to confine share traffic to relays you run (a single Docker container). veld network share refuses to share a run whose config sets no relay. Config wins over the legacy VELD_SHARE_RELAY env var — which is read from the daemon's environment, not your shell, and is not an enforceable floor (a project setting "relays": "public" overrides it). The custom-relay guarantee covers both legs: the joining side automatically confines to the relay(s) advertised in the ticket, so a custom-relay share is never joined over n0's public relays — a joiner only needs VELD_SHARE_RELAY + VELD_SHARE_RELAY_TOKEN on their daemon to supply a token for a token-gated relay. The daemon binds one iroh endpoint per relay policy on demand, so shares on different relays (e.g. one project on public, another on your private relay) run side by side — no conflict, no restart.
n0's public relays are for development and testing, not production. They're a free community service shared across all iroh users worldwide — rate-limited, best-effort, and carry no uptime or throughput guarantees (a share stuck on
relayedis capped by that relay). Per iroh's own guidance, production or high-volume sharing should run on self-hosted relays ("relays": ["https://relay.example.com"], one Docker container — see Relay auth tokens) or n0's managed relays. This is n0's fair-use recommendation, not a licensing restriction — iroh is dual-licensed MIT/Apache-2.0 and free to use commercially.
A self-hosted relay can require an authorization token so it isn't open to anyone. Write a relay as { "url": ..., "token": ... } and Veld sends the token as an Authorization: Bearer header. The token can be a literal string, or — to keep the secret out of veld.json — { "env": "VAR" }, { "file": "/run/secrets/…" } (Docker/K8s mounts), or { "argv": ["op", "read", "op://vault/relay/token"] } (1Password/Vault CLI). It's resolved on the daemon at share time; if it can't be resolved, the share fails rather than connecting unauthenticated. Config tokens apply to hosting only. The join side derives the relay from the ticket automatically; if that relay is token-gated, the joiner is prompted for the token (browser overlay or veld network join terminal prompt) and it's cached per relay so future joins don't re-ask. A wrong token re-prompts. The token can also come from VELD_SHARE_RELAY + VELD_SHARE_RELAY_TOKEN (sent only when it matches the ticket's relay), or — to skip joiner setup entirely — the host can set sharing.dangerouslyEmbedRelayTokensInTicket: true to embed the token in the ticket, which is dangerous (the relay secret then travels in every share link; disposable tokens only). See Relay auth tokens.
share.expose is a list of audiences. peer (Veld-to-Veld, described above) reproduces the origin URL verbatim. web exposes a service to anyone with a browser — no Veld required — via a self-hosted gateway.
A "protocol": "tcp" port — a database, a debugger — can be shared to the peer audience. It rides the same encrypted tunnel and is reproduced on the joining machine as a bare local TCP port: a listener spliced to your service, with no Caddy route in front of it, because a raw connection carries no hostname for a proxy to match on.
The port number your colleague uses is theirs, not yours. Nothing is in front of the socket to preserve 5432, so veld network join prints raw endpoints apart from the URLs and never as links:
✓ Joined — 3 endpoint(s) now reachable on this machine:
https://web.demo.acme.localhost
https://api-admin.demo.acme.localhost
api-postgres.demo.acme.localhost:49317 (tcp)
(--json puts them in addresses; urls stays URLs only.) Two things follow from "no route": proxy header rules are an HTTP concept and don't apply, and a colleague running an older Veld refuses the whole join rather than reproducing a raw endpoint as an HTTP route — the manifest entry has no url and their wire format required one. That is deliberate and fail-closed; upgrade both sides.
tcp is peer-only: "expose": ["web"] requires "protocol": "http", and the combination is the web-share-needs-http lint error. The gateway speaks HTTP/1.1 over the tunnel and a browser cannot speak a raw protocol through it whatever the gateway does — that is what the web audience is, not a limitation waiting to be lifted. There is no udp protocol, and so no udp sharing.
Point the environment at your org's gateway and opt services into the web audience:
{
"sharing": {
"relays": ["https://relay.acme.internal"],
// token is resolved in the daemon's environment (not your shell) — see the note below
"gateway": { "url": "https://share.acme.internal", "token": { "file": "/run/secrets/gw-token" } }
},
"nodes": {
"frontend": {
"variants": {
"local": { "type": "long_running", "argv": ["npm", "run", "dev"], "share": { "expose": ["peer", "web"] } }
}
}
}
}
veld network share --web # prints https://<slug>.share.acme.internal per service + a password
The gateway token (and any relay token) is resolved in the daemon's environment, not your interactive shell — a bare export … won't reach a background daemon, so use a literal (quick start), a file secret mount (production), or set the variable in the daemon's service definition. Same rule as relay auth tokens.
veld network share --web mints a separate share scoped to the web-opted http ports (its own capability — revoking the web audience never touches peer shares), registers it with the gateway, and prints the public URLs. The gateway joins over iroh like any peer and reverse-proxies the tunneled service onto https://<slug>.<gateway-domain>. URLs are deterministic (a hash bound to your machine, the service, and the share) and survive gateway restarts; a new share mints new URLs. The daemon keeps the registration alive with heartbeats; veld network unshare (or the share's TTL) kills the public URLs.
Web shares are password-protected by default. veld network share --web generates a share password (or takes yours via --password) and prints it next to the URLs; the first visit shows a password page, then a session cookie keeps the viewer in for up to 12 hours (never longer than the share). Send URL and password over different channels for real secrecy — or use the printed one-link (https://…/#veld-key=…), which carries the password in the URL fragment: it never appears in DNS, TLS, server logs, or Referer, so even the convenient form beats a bare link. To opt a service out (anyone with the link is served — the unguessable 128-bit slug is then the only gate), set "share": { "expose": ["web"], "web": { "access": "link" } } in config, or pass --access link for services whose config doesn't pin a mode — an explicit config value always wins over the flag. Viewer sessions are stateless (signed with a key derived from the share's capability), so a gateway restart doesn't log viewers out, and revoking the share invalidates every session instantly.
WebSockets (HMR) work through the gateway; redirects to shared sibling services are rewritten to their public URLs. Fidelity is best-effort by design: the app sees its own origin hostname (dev-server host allow-lists pass untouched), the public host arrives in X-Forwarded-Host, and response cookies scoped to origin hostnames are made host-only. Apps with hard-coded absolute URLs, strict CORS allow-lists, or OAuth redirect URIs need those configured for the public host — that's the operator's domain setup, not something Veld rewrites. One password caveat for multi-service shares: the session cookie is per public host, so a password-protected API called cross-origin from a shared frontend will get 401s — give API nodes "web": { "access": "link" } (their slugs stay unguessable and only the app's code ever uses them).
In the browser, the toolbar's arc menu has a top-level Sharing item (a green dot marks it when the current page is already on the public web) that opens a submenu: Start sharing / Stop sharing toggle a web share for the current page's run without touching the terminal, Copy public URL swaps the host of your current page for the public one — keeping path, query, and hash, so a deep link to the exact screen you're looking at lands on your recipient's screen too — and Sharing status reports whether the page is shared and its public URL. Transport detail (direct vs relayed via <relay>, RTT, throughput warnings) lives in veld network list and the management UI, not the in-page toolbar.
Deploying the gateway is one container (ghcr.io/prosperity-solutions/veld-gateway) plus a wildcard DNS record — see the gateway operator guide.
Upgrading: opt-in is a behavior change. Before,
veld network shareexposed every URL-bearing service in a run; now it shares only ports that declareshare.expose, and errors (naming the candidates asnode:variant#port) if none have opted in. Add"share": { "expose": ["peer"] }to the variants you previously relied on sharing — that spelling still works and now means their primary port, which on a single-port node is the only thing it ever meant. Password-by-default is a second behavior change: existing web shares gain a password on upgrade, and a freshly-upgraded daemon refusesveld network share --webagainst a gateway too old to enforce it (clear error) — upgrade the gateway image, or share with--access link.
If the consumer already runs the same environment, the local URL wins — that node is skipped and reported as a warning. Shares live in the daemon's memory: if the daemon stops, shares stop (fail-closed). Stopping the run (veld orchestrator stop) auto-unshares its shares, and so do veld orchestrator restart and a veld orchestrator start that replaces a live environment of the same name — each tears down the ports a share points at and mints a new run, so the old share is released rather than left pointing at nothing. A share whose run ended some other way (a crash) is listed as a "share without a run" in the dashboard, with an Unshare button, so it never becomes unreachable. The consumer's join self-tears-down when the tunnel closes. veld network unshare and veld network leave take the id optionally, resolving the sole active share/join when omitted. Run names are unique per project, not across your machine — two repos both on main each have an environment named main — so veld network share resolves the run against the project directory you run it from. (Two checkouts of one repo are the exception worth knowing: they share a veld.json, so the same run name there means the same URL. veld orchestrator start refuses the second one with an error naming the other project rather than hijacking its route — start it under a different --name.) A name that project doesn't run is an error naming where it does run, so cd there; run from outside any project, an ambiguous name is rejected naming the candidates rather than guessed at. Sharing also requires the run to be running, not merely started: a stopped environment's URLs point at ports that are gone, and one still coming up would share only the services that happen to be up already. Default TTL is 240 minutes, or 120 for --web — the audience is the open internet, so idle web shares die sooner. Both are settings (sharing.peerTtlMinutes / sharing.webTtlMinutes), overridable per project in veld.json and per share with --ttl.
veld self setup privileged (clean URLs without port numbers, custom apex domains, and — on macOS — keeping the machine awake with the lid shut on battery)Veld ships skills for AI coding agents (Claude Code, Cursor, Codex, Windsurf, and 40+ more). Install them so your agent knows how to configure and use Veld:
npx skills add prosperity-solutions/veld
This installs one skill, veld, the general one, covering CLI usage, veld.json configuration and IDE customization.
It is a pointer, and stays a few kilobytes. The documentation itself is carried by the veld binary and served by veld agent skills, so an agent reads the docs for the veld you have installed rather than for whichever version was current the day you ran npx skills add. That also means the skill never needs re-adding, and that nothing enters your agent's context until it asks:
veld agent skills # the index — thirteen topics, one line each
veld agent skills basics # the command surface and the traps, ~4KB
veld agent skills config # the full veld.json reference, when authoring one
Project state is on the same terms: veld orchestrator presets, veld orchestrator nodes and veld orchestrator status answer this project's questions when they come up, instead of a whole veld.json being pasted in up front.
Already installed the old skill? Run npx skills add prosperity-solutions/veld once more. What you have is the previous 100 KB copy; the re-add is what replaces it with the pointer, and it is the last one you will need.
We only accept agentic contributions — see CONTRIBUTING.md for details.
veld orchestrator action <name> [--name <n>] [--node <n>] [--print] [--json] | Run a node-defined action (e.g. open the database in a GUI client); --print emits the resolved command |
veld orchestrator actions [--json] | List the actions defined across the project's nodes |
veld orchestrator logs [--name <n>] [--node <n>] [--lines <n>] [-f] [--since <d>] [--run <id-prefix>] [-p] [--all-runs] [--source <s>] [-s <term>] [-C <n>] [--utc] [--local] [--json] | View logs, scoped to the latest run by default (-f follow — exits 0 when the followed run ends, --run targets a past run by id prefix, -p/--previous the run before the latest, --all-runs restores the old interleaved-across-runs behavior, -s search, -C context lines, --source is one of all (default), server — node output, setup — project setup/teardown steps, internal). Timestamps print in your local time zone; --utc shows them as stored and --local forces local, either one overriding the logs.timeZone setting for that command. --json always emits UTC |
veld orchestrator graph [NODE:VARIANT...] | Print dependency graph |
veld orchestrator nodes [--json] | List all nodes and variants, with the file and line each is defined in |
veld orchestrator presets [--json] [--pin] | List presets with their keys, labels, and when_to_use. --pin prints the current numbering as a block to paste, freezing auto-assigned keys |
veld orchestrator lint [--json] | Check the config for semantic problems — unknown or out-of-scope ${veld.*}, a ${nodes.X} no preset's plan contains, secrets heading for a command line, broken presets. Exits 1 on any error, 0 when only warnings and notices remain — the CI-facing half of the config checks. veld orchestrator start refuses on the same errors |
veld orchestrator runs list [--name <n>] [--json] | List run history — one row per execution instance (short id, started/ended, duration, outcome), newest first. Without --name, all environments' runs grouped |
veld orchestrator runs show <id> [--json] | One run in full: outcome, what it was started from (preset or explicit selections), node results with exit codes, and the graph snapshot it was started with (raw commands, env key names, URL templates — never resolved values) |
veld orchestrator runs diff <old> <new> [--json] | What changed in the config between two runs (node added/removed, command/cwd/env/url changes, veld.json hash). With one id, diffs that run against its predecessor |
veld orchestrator config [--path] [--files] [--why <pointer>] [--json] | Print the config. --files: each include glob, the files it matched, and the nodes each defines. --why: one effective value and where it was defined (a secret is described, never printed) |
veld orchestrator vars list [--json] | Every machine-overridable var: effective value, and which scope it came from |
veld orchestrator vars set <name> <value|--env N|--file P|--shell C> [--worktree] | Answer a machine-overridable var on this machine. Shared by every worktree of the project unless --worktree |
veld orchestrator vars unset <name> [--worktree] | Forget this machine's answer, falling back to the next scope and then the config default |
veld orchestrator init | Create a new veld.json (veld also reads veld.jsonc) |
veld ide open <PATH-OR-URL>[:LINE] (--notify|--quiet) [--json] | Show a file or a page in the Veld window, beside the terminal that asked (or in the worktree containing the current directory): markdown, CSV/TSV, code and other text in a file pane, HTML/PDF/images and http(s) URLs in a browser pane. --notify also marks the worktree unread and notifies; --quiet just opens the tab — one of the two is required. Re-opening the same path re-uses its tab. Never falls back to the system opener: exits 3 outside a worktree Veld knows, 4 when no window is showing that worktree. See veld agent skills ide |
veld ide worktree new --branch <B> [--prompt <TEXT>|--prompt-file <PATH|->] --name <TITLE> [--agent <PANE>] [--group <G>] [--outfit <PROFILE>] [--from-here] [--json] | Hand a task off to a new worktree of the project the current directory belongs to, titled --name (required; at most 18 columns so it always fits the sidebar). With a prompt, the worktree lands in the rail's Waiting for you section (or the existing group named by --group, alias --lane), marked as not started, and the first window that opens it starts an agent pane there with the prompt as its first message — --agent picks the ide.panes entry, defaulting to the one you usually pick. An existing branch, an unknown group or an undeclared agent is refused before anything is created. Nothing reports back to the caller. --from-here cuts the branch from this checkout's HEAD. --outfit pins the new worktree to an outfit profile, and is required where outfit has profiles to offer (unless ide.outfit.required or the outfit.required setting relaxes it). Exits 3 outside a worktree Veld knows, 4 for an unknown group, 5 for an unknown agent, 6 when no answer arrived in time (it may still be created — check the rail rather than retrying), 7 when an outfit profile is needed or not offered (nothing created), 8 when it was created but outfit could not apply the profile (no agent waits in it). See veld agent skills ide |
veld ide worktree name <TITLE> [--json] | Set the title the rail shows for the worktree this runs in (at most 18 columns) — what a coding agent does early in a conversation. Refused on the main checkout and on a worktree whose name was already chosen (by you, a hand-off, or another terminal's agent). Exit: 0 named, 1 failed, 2 not a usable title, 3 not inside a worktree Veld knows, 9 the name is kept |
veld ide worktree groups [--json] | List the groups of this project's rail that veld ide worktree new --group can file into |
veld ide pane restart [--fresh] [--prompt <TEXT>] [--json] / veld agent restart … | From inside a Veld agent pane: ask the window to restart it — resumed with the pane's resume command so the conversation carries on, or --fresh. Exits 3 outside a Veld terminal, 4 when no window shows the worktree, 5 for a pane that cannot be restarted or resumed |
veld ide open-url <URL|file> | Open a web page — or a local file Veld can show — in the Veld window that owns this terminal (a browser pane beside it). Falls back to your system browser outside a Veld terminal, for an origin on the exempt list, for a file kind you have switched off, or when no window is attached — and for a file it falls back to the system opener, which for a .md is your editor rather than a browser. A Veld terminal points $BROWSER at this, so most CLIs reach it without being told to |
veld ide dashboard | Open the management dashboard in the browser |
veld agent skills [TOPIC] [--json] | Veld's own agent documentation, carried by the binary. No topic lists the thirteen; a topic prints it. Needs no project, no config and no daemon — and describes the veld that printed it, which is what makes the installed skills able to stay a pointer |
veld network share [RUN] [--node <n>]... [--ttl <secs>] [--approve <first|manual|auto>] [--web] [--access <password|link>] [--password <pw>] [--json] | Share a running env over an encrypted P2P tunnel; prints a join URL (and veld network join command). --ttl overrides the link's lifetime for this share (default from sharing.peerTtlMinutes / sharing.webTtlMinutes, or the project's veld.json). --web: publish the web-opted services via the configured gateway and print public URLs — password-protected by default (--access link to opt config-silent nodes out, --password to choose the password) |
veld network join <TICKET> [--label <n>] [--no-remember] [--json] | Join a shared env by ticket; materializes the shared URLs locally (blocks until approved). --no-remember: don't cache a relay auth token entered at the prompt |
veld network list [--json] | List active shares, joins, and pending join requests. Each live tunnel shows its transport: direct (full bandwidth) or relayed via <relay> (throughput limited by the relay) plus RTT |
veld network approve <REQ_ID> [--json] | Approve a pending join request |
veld network deny <REQ_ID> [--json] | Deny a pending join request |
veld network unshare [SHARE_ID] [--json] | Stop hosting a share (defaults to the sole active share) |
veld network leave [JOIN_ID] [--json] | Disconnect from a joined share (defaults to the sole active join) |
veld self update [--force] [--verbose] | Update the whole release — CLI, daemon, helper, and on macOS Veld Desktop when you asked for it (veld self desktop install/uninstall is the answer; the first interactive update on a machine that was never asked puts the question). Asks before closing a running app and reopens it afterwards; a non-interactive run leaves it alone. Never asks for a password: the privileged helper restarts itself on request over its own socket, and sudo is offered only if that and its binary watcher have both failed. Only one update runs at a time; --force takes over from a run you know is dead, --verbose shows the install script's own output instead of veld's step summary |
veld self update --status [--json] | What the update that is currently running is doing — who started it, which release, which phase. Installs nothing |
veld self desktop [status [--json]] | Where Veld Desktop is installed, whether it matches the CLI, and whether you asked for it (preference: wanted, unwanted, or null for never asked). --json also lists what this CLI can be asked to do (capabilities) |
veld self desktop install | Install the Mac app (macOS), and remember that you want it — every later update keeps it in step |
veld self desktop uninstall [-y] | Remove the Mac app and remember that you don't want it — installs and updates skip the app half from then on. Works with no app installed, which is how a machine that only orchestrates opts out up front |
veld self desktop update [--relaunch] | Update the installed app only, to this CLI's version. veld self update covers this — reach for it when you want the app half on its own |
veld self gc | Clean up stale state and logs |
veld self setup [unprivileged|privileged] | One-time system setup |
veld self settings list [PREFIX] [--json] | Every Veld setting: effective value, whether it was set or is the default, and what it is. PREFIX narrows to whole key segments (veld self settings list keepAwake). These are Veld's own machine-wide preferences — the ones the IDE's settings dialog edits — not a project's config |
veld self settings get <key> [--json] | One effective value, printed bare so it drops into a shell substitution |
veld self settings set <key> <value> [--json] | Change one setting. Values are written bare (true, 60, /bin/bash); a list takes JSON or commas. A number outside its range is clamped rather than refused, and the clamp is reported |
veld self settings unset <key> [--json] | Put one setting back on its default |
veld self settings describe <key> [--json] | What a setting does, its type, its default, and every value it accepts — including whether an out-of-range number will be clamped |
veld self backup list [--dir D] [--json] | Every copy of Veld's database: when it was taken, its schema version, its size, and whether it can actually be restored. Warns above the table when nothing has been written for two intervals. Works when the database itself will not open |
veld self backup now [--dir D] [--json] | Take a copy immediately, outside the daemon's schedule. Retention still applies |
veld self backup restore [PATH] [--dir D] [--force] [-y] [--json] | Put a copy back. With no PATH, the newest usable one — not simply the newest file. The database being replaced is renamed, never deleted. Refuses while the daemon is running unless --force |
runsnodesenvironmentsnotifyagent_startagent_settledagent_startagent_settledUserPromptSubmitStopargvclaudeveld self doctorCLAUDE_CODE_PLUGIN_DIRSenvmodargvclaudeterminal.shellIntegrationsession_shutdown/new/resume/forkFix the login redirect loopWorkspace 2checkout-v2Größe änderngroesse-aenderncafécafegroessegr-e-2env(safe-area-max-inset-*)0pxenv(safe-area-inset-top, 12px)0pxnavigator.userAgentDataSec-CH-UA+