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.
Running voltro dev in a container
If your dev pod runs as root with the host workspace bind-mounted, everything
the framework generates would otherwise land root:root inside your own tree —
and on the host voltro build then fails on its own output:
EACCES: permission denied, open '…/apps/display/.framework/index.html'
voltro dev and voltro build hand their generated output (.framework,
.env.local, every *.generated.*) to whoever owns the app root, and warn
loudly when they cannot. Only generated state — the framework never takes
ownership of a file you wrote.
The cleaner fix is on your side and worth doing anyway: start the container as
the workspace owner, docker run --user $(id -u):$(id -g). Then nothing needs
handing over at all.
What the boot tells you
Three checks run at boot and print one line each when they have something to say — never fatal, and silent when the answer is fine:
- A reactive table with no change trigger (postgres). The schema fingerprint
covers columns, not triggers, so a restored dump or a hand-run
DROP TRIGGERleaves the schema "up to date" and the subscription silently not reaching other instances. The reverse is reported too: a.nonReactive()table still carrying a trigger paysREPLICA IDENTITY FULLand a NOTIFY per write for nothing. - A
.nonReactive()table that a query reads. That subscription will never fire — first snapshot, then silence forever. apiKeys: truewith noapikeys:issue:*scope declared. The capability is on and reachable by nobody; every issue request fails its guard.
voltro doctor adds a fourth, over your source: its authz scan asks of every
executor — queries included — whether it references an access check at all, and
lists the ones that reference none. It learns your own require* / assert*
guard names, so it does not report the call sites of guards you already wrote.
See the authz scan.
When the database is not reachable
A refused, unresolvable or rejected database connection is reported as a condition with a fix, not as a framework crash:
voltro: the database is not reachable at 127.0.0.1:5432 (ECONNREFUSED).
No database is configured — none of DB_URL / DB_HOST / PG_HOST is set in the
environment or in a loaded `.env`, so the framework used its local dev default.
Either start one: pnpm db:up (if your project ships a compose file)
or point at your own: DB_URL=postgres://user:pass@host:5432/dbname
— put it in `.env` next to app.config.ts, not just in one shell.
Re-run with --debug for the full stack.When a variable is set, the message names it and says the address resolved and nothing answered there — which is a different problem from "is postgres running", and points you at the container, VPN or firewall instead. A server that answers and rejects you (wrong password, missing database) is reported as its own case, because the fix is different again.
Pass --debug (or set VOLTRO_DEBUG=1) to get the full Effect stack instead.
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.
A failed server render fails the request
If the server render throws, voltro dev answers 500 with the error and its
stack, marks the response x-voltro-rendered-by: ssr-dev-failed, and logs it at
error. It does not fall back to a client-rendered shell.
That is deliberate, and it is the same outcome voltro start produces in
production. A fallback would hand you a page that renders in the browser and a
500 in production from the identical code — and because an empty <div id="root"> is what a client-only app looks like, the usual conclusion is "the
framework does not server-render", not "my page threw". The failure is loud so
the cause is the thing you see.
The practical consequence: anything that only misbehaves under
renderToPipeableStream — a component that suspends with no <Suspense>
boundary above it, a loader that throws, a hydration-unsafe value — surfaces in
voltro dev at the moment you hit the route.
Suspending is fine here, and does not need a boundary you add. A component
that suspends during a streamed server render — a lazily-loaded translation
catalog, a React.lazy component, react-i18next with useSuspense: true —
renders normally: renderToPipeableStream treats the root as an implicit
boundary, so a suspend delays the shell flush instead of failing. Measured, not
assumed; a regression test pins it.
Do not add a blanket <Suspense> at the root to "fix" a suspend. It makes
things worse in a way that is hard to see: React downgrades an errored boundary
to client rendering, so a page that THROWS starts answering 200 with
<template data-msg="Switched to client rendering"> instead of failing. You lose
the hard failure above and gain nothing — the suspend already worked. Mount
boundaries where you want a fallback (loading.tsx per route, <Await> for
deferred loader values), not to make suspending legal.
Two places where a suspend genuinely is not supported, both by React rather than
by choice: renderToString, which backs the static prerender
(renderMode: 'static'), emits the fallback instead of waiting — so a suspending
component in a prerendered page needs its own boundary or a resolved value; and
the client render after hydration, which follows React's own rules.
Under VOLTRO_LOG_LEVEL=debug each cold compile logs its own duration, so a slow
first paint can be attributed to a specific module:
[voltro:dev:web] ssr cold-compile start id=/app/src/pages/layout.tsx
[voltro:dev:web] ssr cold-compile start id=/app/src/pages/(main)/layout.tsx
[voltro:dev:web] ssr cold-compile end 3743ms id=/app/src/pages/layout.tsx
Read the ms on the end line rather than subtracting timestamps: compiles run
concurrently up to the cap, so the start and end lines interleave and adjacent
lines usually belong to different modules. The duration is measured inside the
concurrency permit, so it is that module's own compile cost and not time spent
queued behind the cap. A compile that threw says FAILED instead of end.
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
The overlay's webhooks / traces / indexes panels poll each api's /_voltro/inspect/* endpoints, and that surface is fail-closed everywhere: with no VOLTRO_INSPECT_TOKEN configured, nobody is authorised — voltro dev included.
Under voltro dev you configure nothing. The dev server mints a token per project and its proxy attaches the Authorization: Bearer header server-side, on the /_voltro/api/<name> route the panels fetch through. The token stays in the dev server's process; the browser never holds it.
That is deliberate rather than convenient. A token compiled into the client bundle is a live credential published to everyone who loads the page, so there is no env-var channel for it — voltro dev and voltro build set vite's envPrefix to a sentinel precisely so nothing leaks through import.meta.env.
For an api the dev proxy does not front — a voltro start deploy with VOLTRO_INSPECT_TOKEN set, say — pass the token explicitly, and note that whatever you pass ships in the bundle:
import { VoltroDevtools } from '@voltro/devtools'
<VoltroDevtools inspectToken={myToken} />Without the prop the overlay sends no Authorization header of its own, which is correct: under voltro dev the proxy has already added one. (The indexes panel's live SSE stream can't carry a header at all; against an api reached without the proxy it falls back to token-carrying HTTP polling.)
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). See Which port an app binds for the full order. |
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
The dev script the scaffolder writes at the workspace root is plain pnpm — no task runner to install:
pnpm dev # ↳ pnpm -r --parallel dev — `voltro dev` in every app at oncepnpm -r selects by "has a dev script", so an app that owns its own dev loop (an Expo mobile-app, an edge-functions bundle) opts out simply by not defining one. To run a single app: pnpm --filter @acme/api dev.
If you prefer a task runner for its caching and per-app output panes, adding one is a normal workspace change — nothing in the framework depends on it.
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.
Staleness is detected, not assumed
The generated file carries a source-fingerprint of the descriptor tree, so the other commands can tell whether it still matches your code:
voltro buildregenerates it when it is stale. A CI build from a clean checkout never ranvoltro dev, and it is a file the build can produce itself.voltro testREFUSES and tells you to runvoltro codegen:voltro test: …/rpcGroup.generated.ts is stale — a descriptor changed since the group was generated. The tests would run against the previously generated procedure group, pass, and prove nothing about the descriptors you just edited. Run `voltro codegen` (or boot `voltro dev` once) and try again.It refuses rather than regenerating because regenerating means importing your app's modules and config as a side effect of asking to run tests, and silently rewriting a checked-in source file is worse than stopping.
The check reads bytes only — no app module is imported — so it costs milliseconds. A generated file written by an older framework version carries no stamp and reads as stale; run voltro codegen once.
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.
Those names are matched as whole path segments, so a directory called distribution/ or a file called distTools.ts is watched normally.
For web apps, Vite's built-in HMR handles the watch.
Workspace packages are watched too
If your api depends on a workspace package ("@acme/shared": "workspace:*"), that package's src/ is watched as well — editing packages/shared/src/x.ts restarts the api, exactly as editing a file inside the api would. The dependency set is resolved once at boot from the api's package.json, so adding a dependency needs a restart (it needs an install anyway).
Only real workspace packages are watched. A published npm dependency resolves inside node_modules and is skipped, so an app outside a monorepo watches nothing extra.
An edit in a dependency is logged with its package directory, not just the filename:
file changed — restarting file=shared/src/x.ts
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).
Reading the restart timing
The completion line is printed when the api can serve a request — not when the replacement process was spawned:
file changed — restarting file=notes.list.query.ts
restart complete — api ready ms=1840The first boot reports the same measurement as dev server ready. If a restart never prints its completion line, the child did not come up — look for the crash above it, which the supervisor logs before it goes back to waiting for the next save.
That number is the whole wait: the process start, the module graph, discovery, codegen, the store connection, the boot schema diff and plugin boot. It is the number to quote if the inner loop feels slow.
How the old process is stopped
SIGTERM first. The child runs its teardown — plugin onDeactivate, the
CDC detach, the scheduler and workflow runtime, the connection pool, and
every ctx.onShutdown(cb) a *.startup.ts registered — and then exits.
That is normally tens of milliseconds and you never see it.
It gets 1.5 seconds, then SIGKILL, and the escalation says so:
child ignored SIGTERM — escalated to SIGKILL pid=41207Read that as "something in this app's shutdown does not complete" — a
pool draining against a database that is already gone, a plugin
onDeactivate waiting on a dead socket. The restart still happens; it
just costs the full grace window every time, and whatever teardown had
not finished was cut off. Worth fixing at the source rather than living
with, because the same hang is a slow — then failed — shutdown in
production.
Neither timeout is optional: a stop that can wait forever is a dev server that stops restarting entirely, with the old process still holding the port and your browser's websocket still attached to code you edited minutes ago.
When the dev server stops
A restart replaces the child; the supervisor keeps watching. When the dev server exits on its own — an aborted boot, or you stopping it — what happens next depends on whether anyone is there to react.
In a terminal, a crashed boot is something you are about to fix, so the supervisor keeps watching and tells you:
dev server crashed — waiting for a file change exitCode=1Fix the cause and save. The watcher restarts the server exactly as it would for any other edit; you don't retype the command.
Piped, backgrounded or in CI — anywhere stdout is not a TTY — nobody
is going to fix anything, so voltro dev exits with the child's code:
dev server exited — supervisor stopping exitCode=1That half matters because the supervisor used to keep watching in both
cases: a shell that had long since closed still had a voltro dev behind
it holding a watcher, and a CI job that had "finished" kept its runner
busy. A failed boot is now a failed command wherever no one is looking.
A clean exit always stops the supervisor, watched or not — a dev server ending on purpose is not something to wait out.
| Env | Effect |
|---|---|
VOLTRO_DEV_KEEP_ALIVE=1 |
Wait for a fix even without a TTY — a CI runner with a TTY allocated, or a wrapper that pipes output while you watch it. |
VOLTRO_DEV_KEEP_ALIVE=0 |
Exit on a crash even in a terminal. |
Neither can keep a clean exit alive; that would turn a deliberate shutdown into a hang.
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.
Which port an app binds
Every command that starts an app listener — voltro dev, voltro serve,
voltro start, voltro dormancy — resolves the port the same way, in this
order:
VOLTRO_DASHBOARD_PORT, and only in the auto-launched dashboard process.PORTfrom the environment.--port <n>(voltro serve,voltro dormancy).port:in the app'sapp.config.ts.4000for an api app,5173for a web app.
PORT deliberately outranks --port: every host that assigns a port — a
container platform, a PaaS, a Kubernetes Deployment — assigns it through PORT,
and a --port baked into an image's start command must not override the port the
host actually routed to.
A value that is not a port in 1..65535 (PORT=, PORT=8080x) is ignored
with a warning naming the variable, and the next source wins. It is not passed to
listen(): Number('8080x') is NaN, node reads that as "any free port", and
the app would come up healthy at an address nobody can guess.
voltro dev does not take --port. Its file-watching supervisor respawns the
app as dev <app> and drops flags, so a --port would silently stop applying at
the first file change; set PORT for a one-off, or port: to keep it.
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 workspace root handles all of this for you.
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.