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 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.

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 app

Turbo'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 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.

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.

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.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).

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 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.