Dev
voltro dev, codegen, agents-md — what runs during local development and the env flags that shape it.
voltro dev is the day-to-day command. It runs different machinery for api vs web apps but the contract is the same: edit a file, the right thing happens.
voltro dev <appDir>
voltro dev . # current dir
voltro dev apps/acme/api # explicit pathWhat it does for an api app
- Reads
app.config.ts. Bails if nottype: 'api'. - Walks
queries/,mutations/,workflows/, etc. for the discovery patterns. - Generates
.framework/rpcGroup.generated.tsexporting a typed client. - Starts the RPC over WebSocket server on
:4000(override withPORT, or setport:inapp.config.ts). - Watches every discovery-matching file. On save:
- File added/removed → regen the discovery → restart the api process.
- File modified → reload the module → fire
hmr updateto connected clients.
- Resolves
STORE=memory(in-memory store) orSTORE=postgres(real DB). - Auto-launches the inspect dashboard on
:5179(unlessVOLTRO_DASHBOARD=off).
What it does for a web app
- Reads
app.config.ts. Bails if nottype: 'web'. - Walks
src/pages/for queries + special files. - Generates
.framework/main.tsx,.framework/app.tsx,.framework/routeTable.ts,index.html. - Starts Vite with the framework's plugin chain (React, Tailwind v4, page discovery, inspect, dashboard registry).
- Binds to the configured port from
app.config.ts.port(strict — fails on conflict). - Watches
src/. On save, Vite HMR fires:- A page / layout component change → React Fast Refresh patches the live component in place; client state survives (see below).
- A page-local value export (
const COLUMNS = [...]) change → also a hot update, even in the same save as the JSX (see below). - A server-read export —
loader,renderMode,meta, … — change → a full page reload, on purpose (see below). - CSS changes → swap stylesheets in place.
- New page file → regen the entry files → reload the route tree.
renderMode: 'ssr' pages compile on demand the first time each route is hit. To
keep a burst of cold pages — several browser tabs, or a health-check sweep across
many routes — from spiking memory, voltro dev compiles at most 4 of them at
once and collapses duplicate concurrent requests for the same route into a single
compile. Already-compiled (warm) pages are never throttled, so a hot app stays
fully concurrent. Tune the cap with VOLTRO_DEV_SSR_COMPILE_CONCURRENCY (below) —
drop it on a low-memory box, raise it on a big machine.
Fast Refresh: what hot-updates and what reloads
Editing a page or layout component applies as a hot update — the React tree
stays mounted, so form input, scroll position, open dialogs and every useState
survive. So does editing a page-local value the page happens to export — a
const COLUMNS = [...] you change together with the table that renders it. The
module re-evaluates, the component renders the new value, and your client state
is untouched.
A full page reload happens for exactly one class of edit: an export the server already read to produce the page in front of you.
| Export | What the server does with it |
|---|---|
loader |
runs it (SSR / prerender), and the router caches the result per route + params |
renderMode, dynamic |
picks the render strategy for the route |
meta |
renders it into <head> |
getStaticPaths |
enumerates which paths get prerendered |
revalidate, staleWhileRevalidate |
sets the ISR cache window |
cacheInvalidatesOn |
wires the page into the ISR cache invalidator |
interactive |
decides how much client JS is shipped |
tenantAware |
forms part of the server-side cache key |
That reload is deliberate, not a gap. The HTML you are looking at was produced from the OLD value, so hot-swapping the export would leave stale output on screen with nothing to signal it. A reload re-runs SSR with the new value, and the console line names the export and the server step that consumed it.
The mechanism, in case you hit an edge: React Fast Refresh only accepts a module
whose exports are all components, and a page exporting loader beside its
component fails that test. voltro dev registers each route module's
non-component exports with the React plugin's ignore hook (so Fast Refresh
judges only the components), then makes the reload call itself by comparing the
server-read exports' VALUES across the update — a function by its source text,
anything else by its JSON form — so a JSX-only edit, which recreates the
loader function object, is correctly read as "unchanged".
One residual caveat: adding or removing a non-component export still reloads once, whatever it is. Fast Refresh sees an export that was not on the ignore list yet and refuses the boundary; the next edit to that page hot-updates normally.
The in-page devtools overlay
The generated web entry auto-mounts the @voltro/devtools overlay — a floating button that expands into live panels (subscriptions, mutations, indexes, webhooks, traces, routes, runtimes, logs; Alt+V toggles it). The component AND its stylesheet load dynamically under import.meta.env.DEV only; production builds strip the import entirely, so it needs zero code and ships zero bytes to prod. Opt out per app in app.config.ts:
// app.config.ts (web app)
export default {
type: 'web' as const,
name: 'web',
disableDevtools: true, // no overlay import, no mount, no stylesheet
}Inspect token (token-gated deploys)
The overlay's webhooks / traces / indexes panels poll each api's /_voltro/inspect/* endpoints. Under voltro dev that surface is open, so no auth is needed. A token-gated deploy (voltro start with VOLTRO_INSPECT_TOKEN set) requires the same Authorization: Bearer <token> the CLI sends — otherwise the panels 401 to their empty state.
The overlay reads that token from VITE_VOLTRO_INSPECT_TOKEN (only VITE_-prefixed vars reach the browser bundle). A foreign-host mount can also pass it explicitly:
import { VoltroDevtools } from '@voltro/devtools'
<VoltroDevtools inspectToken={import.meta.env.VITE_VOLTRO_INSPECT_TOKEN} />When neither the prop nor the env var is set, no Authorization header is sent — local dev is unaffected. (The indexes panel's live SSE stream can't carry a header; a token-gated deploy falls back to token-carrying HTTP polling for that panel.)
Overriding the overlay's labels
Every user-facing label the overlay renders (tab labels, empty states, the FAB tooltip, …) routes through an overridable strings seam — the same pattern as @voltro/ui's UiStringsProvider. Localize or rebrand by passing strings (deep-merged onto the English defaults — supply only what you change):
import { VoltroDevtools } from '@voltro/devtools'
<VoltroDevtools
strings={{
tabs: { subscriptions: 'Abos', logs: 'Protokolle' },
shell: { openLabel: 'Voltro Devtools öffnen' },
}}
/>A <DevtoolsStringsProvider strings={…}> mounted above the overlay works too; nested providers compose.
Common flags + env vars
| Var / flag | Notes |
|---|---|
STORE=memory |
In-memory data store (default). Restart = state gone. |
STORE=postgres |
Real Postgres via DB_URL (or the discrete DB_* / PG_* fields). Survives restarts. DB_DIALECT picks the SQL backend. |
WATCH=0 |
Disable filesystem watch. Useful under a parent watcher (Docker volume, devcontainer). |
VOLTRO_DASHBOARD=off |
Don't auto-launch the dashboard. |
VOLTRO_INSPECT=off |
Don't expose /_voltro/inspect/* endpoints. |
PORT=4001 |
Override the listen port (api or web). Both kinds read PORT, then app.config.ts port:, then the default. |
VOLTRO_DASHBOARD_PORT=5180 |
Override the auto-launched dashboard port (default 5179). |
VOLTRO_LOG_LEVEL=debug |
Verbose framework logs. |
VOLTRO_DEV_SSR_COMPILE_CONCURRENCY=4 |
Max ssr pages compiled on demand at once (default 4). Lower it (1/2) on a low-memory box if a burst of first-time ssr page loads spikes memory; raise it on a big machine. Warm (already-compiled) pages are never throttled. |
Multi-app dev (turbo)
The pnpm dev at the project root delegates to turbo run dev which boots every app in parallel:
pnpm dev # ↳ turbo runs `voltro dev` for every workspace appTurbo's TUI mode shows one pane per app with live output. Set "ui": "tui" in turbo.json (the scaffolder does this for you).
voltro codegen <appDir>
voltro codegen apps/acme/api # regenerate the codegen for one app
voltro codegen . # current dirRegenerates rpcGroup.generated.ts (+ the web .framework/* entry) from the discovered descriptors. It takes only an optional app-directory path — no flags. You rarely need this; voltro dev does it on every save. Useful for:
- CI environments where you want the typed client baked into a tarball before tests run.
- Editor LSP confused after a discovery pattern changed and the generated file went out of sync.
voltro agents-md
voltro agents-md # seed AGENTS.md if it doesn't exist
voltro agents-md --force # overwrite existing fileSeeds the framework agent guide into the repo root under both filenames — AGENTS.md (the universal convention) and CLAUDE.md (project-pinned Claude Code setups) — written atomically from one template so they can't drift. The file teaches AI coding agents (Claude Code, Cursor, GitHub Copilot Chat) Voltro's conventions — file suffixes, schema DSL, query shape, layout contract, anti-patterns.
When the framework's template gains new sections (new file convention, plugin shape change), run voltro agents-md --force to pull them in — --force overwrites BOTH files. The CLI doesn't auto-overwrite on boot, so apps that customised their guide keep their changes until they ask for a refresh.
File watch internals
For api apps, Voltro applies its own discovery walker on every save. The patterns that trigger a re-discovery (and the matching .server.ts executors):
**/*.query.ts,**/*.mutation.ts,**/*.action.ts,**/*.stream.ts(+ their.server.tssiblings)**/*.workflow.tsx,**/*.trigger.tsx**/*.cron.tsx**/*.webhook.tsx**/*.subscribe.ts**/*.aggregate.ts**/*.agent.tsx**/*.email.tsx**/*.seed.ts,**/*.startup.tsx**/*.entity.ts,**/*.schema.ts,schema.tsapp.config.ts
*.tool.tsx files are not discovered on their own — a tool is imported by the agent that uses it, so it's picked up through the agent file. Hidden dirs, node_modules, dist, and .framework are skipped.
For web apps, Vite's built-in HMR handles the watch.
Restart triggers
API apps restart (full process kill) on a change to any source file
under the api project dir — every .ts / .tsx / .mts / .cts /
.js / .jsx / .mjs / .cjs / .json, excluding *.generated.*
(the codegen rewrites those every boot, so watching them would
self-respawn forever). Concretely that includes:
app.config.ts/package.jsonchange- A primitive descriptor / executor, or a new/removed primitive file
- A shared
lib//services/helper that a descriptor or executor imports — editing one respawns the api, because the whole module graph is re-imported on restart (it is NOT enough to watch only the convention files) - A change to any
.env/.env.localfile the process loaded at boot
The restart is a full re-exec — there is no in-process hot-reload of a handler body; editing a query's executor respawns the child (debounced 80ms, so a burst of saves collapses into one restart).
The .env trigger applies to both api and web apps — process.env is
parsed once at boot, so editing a .env (or re-pulling secrets, e.g.
doppler secrets download > .env) needs a full re-exec. voltro dev watches
the env-file chain (app dir → ancestors) and hard-restarts, logging
.env changed (<file>) — hard-restarting…. The api releases its port and the
web closes Vite before the replacement spawns, so the restart can't hit a
port-in-use race.
Multiple instances on one machine
Run two api apps + two web apps in parallel? Every app reads the same PORT env, so prefer setting each app's port: in its own app.config.ts and --cwd-ing into each — that avoids one shared PORT clobbering them all. The dashboard auto-launches once on :5179; later instances see it's already up and skip it.
voltro dev apps/acme/api & # port from apps/acme/api/app.config.ts
voltro dev apps/orbit/api & # port from apps/orbit/api/app.config.ts
voltro dev apps/acme/web &
voltro dev apps/acme/docs &
voltro dev apps/orbit/web &If you must override per-process from the shell, set PORT inline on each one (PORT=4001 voltro dev apps/acme/api) — but the config-file port is the cleaner path. pnpm dev at the repo root handles all of this for you via turbo.
Anti-patterns
- Running
voltro devagainst avoltro startbuild directory. The dev server expects source files; pointing it atdist/confuses it. Usevoltro startfor that. - Ignoring
app.config.tschanges. They require a process restart (the discovery walker re-reads them at boot only). Save, watch the process die + come back up. - Reading "the page reloaded" as "HMR is broken". A page/layout component edit hot-updates and keeps client state; a
loaderedit reloads by design — it also runs server-side, and the rendered page came from the old one. If a pure component edit reloads, look for a non-component export on that route module that changed in the same save. - Expecting a
.envedit to hot-reload. It can't —process.envis read once at boot.voltro devhard-restarts the server on a.envchange (you'll see.env changed … — hard-restarting); wait for the process to come back before testing, rather than assuming the new value is already live. - Disabling the dashboard "to save resources". It's a few MB of RAM + the inspect endpoints fail gracefully. Keep it on; it's the best debugging tool you have.