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 TRIGGER leaves 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 pays REPLICA IDENTITY FULL and a NOTIFY per write for nothing.
  • A .nonReactive() table that a query reads. That subscription will never fire — first snapshot, then silence forever.
  • apiKeys: true with no apikeys: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 path

What it does for an api app

  1. Reads app.config.ts. Bails if not type: 'api'.
  2. Walks queries/, mutations/, workflows/, etc. for the discovery patterns.
  3. Generates .framework/rpcGroup.generated.ts exporting a typed client.
  4. Starts the RPC over WebSocket server on :4000 (override with PORT, or set port: in app.config.ts).
  5. Watches every discovery-matching file. On save:
    • File added/removed → regen the discovery → restart the api process.
    • File modified → reload the module → fire hmr update to connected clients.
  6. Resolves STORE=memory (in-memory store) or STORE=postgres (real DB).
  7. Auto-launches the inspect dashboard on :5179 (unless VOLTRO_DASHBOARD=off).

What it does for a web app

  1. Reads app.config.ts. Bails if not type: 'web'.
  2. Walks src/pages/ for queries + special files.
  3. Generates .framework/main.tsx, .framework/app.tsx, .framework/routeTable.ts, index.html.
  4. Starts Vite with the framework's plugin chain (React, Tailwind v4, page discovery, inspect, dashboard registry).
  5. Binds to the configured port from app.config.ts.port (strict — fails on conflict).
  6. 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 exportloader, 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 once

pnpm -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 dir

Regenerates 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 build regenerates it when it is stale. A CI build from a clean checkout never ran voltro dev, and it is a file the build can produce itself.

  • voltro test REFUSES and tells you to run voltro 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 file

Seeds 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.ts siblings)
  • **/*.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.ts
  • app.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.json change
  • 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.local file 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=1840

The 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=41207

Read 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=1

Fix 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=1

That 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:

  1. VOLTRO_DASHBOARD_PORT, and only in the auto-launched dashboard process.
  2. PORT from the environment.
  3. --port <n> (voltro serve, voltro dormancy).
  4. port: in the app's app.config.ts.
  5. 4000 for an api app, 5173 for 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 dev against a voltro start build directory. The dev server expects source files; pointing it at dist/ confuses it. Use voltro start for that.
  • Ignoring app.config.ts changes. 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 loader edit 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 .env edit to hot-reload. It can't — process.env is read once at boot. voltro dev hard-restarts the server on a .env change (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.