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(orVOLTRO_DASHBOARD_PORT) — when it is installed (pnpm add -D --save-exact @voltro/dashboard— the stability contract asks for exact pins, andpnpm addwrites a caret range without the flag), and unlessVOLTRO_DASHBOARD=off. It is a separate package, so a project that has not added it sees a boot line saying so rather than nothing: an auto-launch that quietly does not happen is indistinguishable from one that failed.
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. Closed, it is a status capsule, at the bottom centre of the page until you move it: the api's connection state, how many subscriptions are live, whether a mutation or action is pending or failed, and whether anything logged an error. Every one of those comes from an in-page bus, so a closed overlay costs no network. Drag the capsule anywhere; within 48px of a corner or an edge midpoint it snaps there, and those eight slots show as outlines while you drag. The placement is remembered with the dock settings and kept relative to the viewport, so resizing the window never pushes it off screen. Open (click it, or Alt+V), it becomes a console that works like a browser's devtools:
One search across every source. Type a name and the list shows matching subscriptions, mutations, actions, traces and log records together. Pending mutations and actions come first, newest start first; subscriptions (live state) follow, then completed events newest first. The search matches names, inputs, messages, span names and error text; quote a phrase to match it whole. ⌘K / Ctrl+K opens the console and puts the caret in the box from anywhere on the page.
Scopes instead of tabs. The chips beside the search narrow the list to one source (Subs, Mutations, Actions, Traces, Logs). Workflows, Stores, Indexes, Webhooks, Routes and Runtimes are structural views, not events, so those chips show their panel as-is. Panel headings span the card width; inset content and wrapping event descriptions keep narrow docks readable.
Pending stays visible. Every scope shows the current pending-call count in the footer. Click it to show only pending mutations and actions, clearing the search and switching to All. Click again or choose a scope to return to the full list. A completed call leaves the pending list; its selected detail stays open so you can read the result or error. Its record remains in the Actions or Mutations scope.
Workflows, in the page that started them. The Workflows scope lists the runs of every connected api over the client's own workflow subscriptions (no inspect token), live: a run expands into its steps with input, output and attempt, its lifecycle events and its trace id; a running run can be cancelled, and one parked on
awaitSignaloffers the awaited signal names with a payload field. Each api carries a start form — pick a workflow, edit the JSON payload (prefilled from that workflow's last run), start, and watch the run appear. The badge on the chip counts running runs.The browser console is a log source. Logs holds every
@voltro/loggerrecord this page produced AND everyconsole.warn/console.error, uncaught error and unhandled promise rejection — the framework's own client-side warnings are console calls, so without this the scope stayed empty in exactly the situations you open a console for. A logger record that is also printed to the console appears once, not twice.console.log/console.infoare not captured.A detail pane beside the list. Select a row (click, or ↑ / ↓ from the search box) and its full record renders on the right: the cache entry's recovery state and data, a mutation or action's input and output or error, a trace's span waterfall, a log record's fields and cause.
Docking and resizing. The dock menu in the header (or Alt+Shift+←↑↓→) moves the console to the bottom, top, left or right edge, turns it into a floating window you can drag and resize, or moves it into its own browser window (the menu only; Alt+Shift+arrows choose an edge). The page is then left exactly as it is, which makes this the mode for checking a layout at full width or putting the console on a second monitor. The console still reads the page's own state; closing the window closes the console, and closing the page closes the window. A browser only opens such a window on a click, so after a reload the overlay comes back as the capsule and the next click on it reopens the window. A blocked pop-up leaves the capsule and logs why. Drag the grip on the console's inner edge to resize; the size is remembered per dock side, like Chrome's, and the whole dock model persists in localStorage.
Overlay by default, push on request. The console lies over the page. The dock menu's Push the page switch lays the page out in the space beside an edge-docked console instead. Fixed-position elements follow the page into that space too, so a dialog's backdrop, a drawer or a toast stays beside the console instead of under it. Two things still describe the whole window, and that is why the switch is opt-in:
100vw/100vhand media queries, and the window's scroll. While the page is pushed it scrolls inside<body>, sowindow.scrollYstays0and a scroll listener onwindowsees nothing (the scroll position is carried over and back). For a page that depends on those, use its own browser window instead.Isolated from the page, and never in its way. The overlay renders inside a shadow root, so the page's stylesheet does not reach it and its own does not reach the page. An error inside the overlay is contained in its own chrome with a reset; the app keeps running.
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
}A foreign host that renders <VoltroDevtools /> itself can set first-run defaults; a persisted user choice always wins over them. initialDock takes bottom, top, left, right, window or external:
import { VoltroDevtools } from '@voltro/devtools'
<VoltroDevtools initialDock="right" initialIsOpen />Inspect token
The console's traces, webhooks and indexes sources 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. They poll only while the console is open and the tab is visible; the capsule's counts never touch the network.
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.
The proxy lends the token only to a requester it trusts. The web dev server listens on every interface, so "whoever reached /_voltro/api/<name>" includes a LAN peer or anyone behind an ingress. It attaches the bearer (and the write factor) for exactly these requesters:
- this machine — a direct loopback connection no proxy relayed. A browser on
localhost, an SSH tunnel andkubectl port-forwardall arrive this way. A reverse proxy on the same host (Caddy, an ingress sidecar, a tunnel daemon) also connects from127.0.0.1but addsX-Forwarded-For/Forwarded/X-Real-IP(or a similar header), so its visitors do not count as this machine. A forwarder that adds no header (socat,ssh -R, a tunnel client) passes the visitor'sHostthrough, and aHostthat is notlocalhost, a*.localhostname,127.x.x.xor[::1]does not count as this machine either; - a client network you listed — set
VOLTRO_INSPECT_TRUSTED_CLIENTSto the networks your team connects from (addresses, CIDR ranges, orprivate), for a deployed dev environment where a per-browser sign-in is the wrong default. Behind an ingress, also declare the ingress inVOLTRO_TRUSTED_PROXIES: the client address is read fromX-Forwarded-Foronly when the TCP peer is a declared proxy, so a visitor cannot name a listed address in a header of their own. Declare the ingress by address or range (privatecovers a cluster's internal ranges); a hop count is refused here, because it believes a forwarded address from any direct caller — a pod or NodePort that reaches the server without the ingress could name a listed address. A request relayed by the ingress withoutX-Forwarded-For(or the header named inVOLTRO_CLIENT_ADDRESS_HEADER) identifies nobody and is not trusted by network. A list containing0.0.0.0/0,::/0or*, or set besideVOLTRO_TRUSTED_PROXIES=*(which believes whatever the client writes) or a hop count, refuses the boot; an active list is printed in the boot log. A loopback peer stays under the rule above. This holds with and without the lock: a listed network needs no sign-in, everyone else gets the form; - a signed-in devtools session — set
VOLTRO_DASHBOARD_TOKEN(16+ characters,voltro secret generate dashboard) on the web server and sign in once from the overlay. It is the dashboard's lock, reused: an HttpOnly, SameSite=Strict cookie fromPOST /_voltro/devtools/login, valid for 12 hours. A locked server trusts sessions only — its own loopback included — because a plain TCP forwarder (socat,ssh -Rwith gateway ports) that exposes the port adds no forwarding header and makes every visitor look like this machine.
Anyone else gets 401 inspect proxy refused with a reason and a fix, and no inspect data. A request that brings its own Authorization header is forwarded unchanged. The page's own traffic on the same prefix (/_voltro/api/<name>/rpc, the capabilities probe) is never refused and never gets the inspect token.
Reading a remote api (split dev deployment)
When the web app and its api run in different pods, let the web server proxy the api rather than pointing the browser at it:
- Leave
apis.<name>.urlunset and setVOLTRO_API_ORIGIN_<NAME>(orVOLTRO_API_ORIGIN) on the web pod to the api's internal origin, for examplehttp://voltro-api:3335. An origin an operator named there (or listed inVOLTRO_DASHBOARD_APPS) is configured: the proxy may send it a token. A remote origin that only comes fromapp.config.tsnever gets one. - Give the web pod the api's
VOLTRO_INSPECT_TOKEN— or list the api's origin inVOLTRO_DASHBOARD_APPSwith itstokenandwriteToken, which also carries the write factor overlay actions need — and list the api's host in the api'sVOLTRO_INSPECT_ALLOWED_HOSTS(herevoltro-api) — its DNS-rebinding guard refuses other names. - Decide who reads through the ingress: list your team's networks in
VOLTRO_INSPECT_TRUSTED_CLIENTSwith the ingress inVOLTRO_TRUSTED_PROXIES(no sign-in), and/or setVOLTRO_DASHBOARD_TOKENon the web pod (one sign-in per browser in the overlay). With neither, onlykubectl port-forwardto the web pod reads the api.
Actions from the overlay (cancel a run, send a signal) are writes, and the api refuses any write whose Origin is not a loopback origin — a page served from a public dev host can read through the session but not act. Run actions through kubectl port-forward or the CLI.
When the surface refuses the overlay
A 401/403 from an inspect endpoint is an answer, not a hiccup. Each source asks once — React's StrictMode, which runs every effect twice in development, does not double it. The console shows which api refused, the status and the reason/fix the server sent, offers the sign-in form when the server has a lock, and stops asking that api until you close and reopen the console. A failure with no status — a network error, or an api on another origin whose refusal the browser hides — backs off exponentially and is reported after three in a row. A refused action (a missing write factor) is shown on the action and does not stop the reads.
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 (scope labels, empty states, the capsule tooltip, the dock menu, …) 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 — the api's restart supervisor and the web app's Vite watcher alike. Useful under a parent watcher (Docker volume, devcontainer), or for a long-running dev server beside a framework or workspace package under active development. |
VOLTRO_DASHBOARD=off |
Don't auto-launch the dashboard. |
VOLTRO_DASHBOARD=on |
Auto-launch it even when stdout is not a TTY. The launcher skips without one, which is a good stand-in for "nobody is watching" — until a task runner is in the way: turbo run dev captures each task's output to prefix it, so a developer sitting right there looks like CI. This overrides only that guess; the port check and the launch lock still apply. |
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_API_PORT=4001 / VOLTRO_WEB_PORT=5174 |
Override the port for ONE app kind — outranks PORT, for a project that runs api and web from one env file. |
VOLTRO_DASHBOARD_PORT=5180 |
Override the dashboard port (default 5179) — for the auto-launch from voltro dev / voltro start (api and web) and for voltro dashboard. Set it on the process that launches it. A value that is not a port (1..65535) is refused with a warning and 5179 is used. |
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.
The browser's copy of the client, and what keeps it fresh
The generated file carries a second stamp, descriptor-fingerprint, and it
answers a different question. source-fingerprint asks whether this FILE needs
regenerating. descriptor-fingerprint asks whether the BROWSER's copy of your
api client is stale — which is a bigger set, because the dev server pre-bundles
the generated group's whole import graph into one optimized chunk, and Vite
never re-optimizes a pre-bundled dependency on a content change.
So the fingerprint covers the descriptors and everything they value-import:
the shared lib/ file holding a typed error, a schema helper, a constants
module — anything whose bytes the browser receives. The dev server hashes that
same set at boot and watches it while running, and re-bundles with a page
reload when it moves, logging one line:
the api surface changed — re-bundling the api client + reloadingThis matters because a mismatch here is otherwise invisible. Add a field to a
typed error the descriptor lists in error: [...], and a browser holding the
older class decodes the failure without that field — an unknown field is not a
decode error, it is simply absent. No exception, no warning, no request to
find in the api log, because the response arrived and decoded fine.
Two things are deliberately outside the set:
- The executor halves (
*.query.server.ts,*.workflow.server.tsx) and anything only they import. The browser never loads them, and including them would re-bundle on every handler edit — which is the whole working day. @voltro/*packages. In an install they are built JavaScript, so nothing is missed; the walk stops there rather than following framework source in a monorepo checkout.
If you ever suspect the browser is holding an older client, .framework/.vite
is a cache: delete it and restart. That is a diagnosis of last resort, not a
routine step — needing it twice for the same edit is a bug worth reporting.
And there is a second check that does not rely on any of that being right. The api publishes a digest of each procedure's schemas as it bound them; the browser computes the same over the client it loaded, and reports a disagreement:
[voltro] the api client this page loaded does not match what the 'app' server
binds: undo.apply. Responses for those procedures may decode against the wrong
schema — a field the server sends and this client does not know is dropped
silently. Reload; if it persists, stop the dev server, delete `.framework/.vite`,
and start it again.The fingerprint above PREDICTS staleness from source bytes. This observes the
schemas both sides actually hold, so it does not care what made them differ — a
gap in the import walk, an optimizer that did not re-run, a service worker, a
cache. It also appears in the diagnostic ring as runtime.surface-mismatch, so
a support export carries it.
It warns and never fails a boot, and it is silent in two cases on purpose: a procedure only the CLIENT has is normal (server-only routes exist), and a field added inside a recursive schema is invisible to the digest, because following a recursive schema would not terminate.
Only voltro dev publishes the digests. voltro serve ships a built bundle
where this staleness cannot arise from a dep optimizer, and a rolling deploy
would make a brief mismatch normal rather than a finding.
voltro agents-md
When the CLI copies its topic guides into agent-docs/, that directory carries the .voltro-generated marker. Development excludes the generated mirror from source watching, so refreshing _manifest.json during startup does not cause a restart loop. Handwritten source and configuration remain watched.
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 (WATCH=0 turns it off too).
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=1840 cli=1.5.0The first boot reports the same measurement as dev server ready. cli is the @voltro/cli version that composed the api — the supervisor's own, since the child is spawned from the supervisor's entry. 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.
A changed @voltro/cli stops the supervisor
The child is respawned from the supervisor's own entry, so a restart always runs the cli version the supervisor started with. If the project's install changes underneath — a voltro update, a pnpm install that bumps @voltro/cli, a node_modules volume swapped under a pod — a restart would boot the OLD cli and print restart complete for it. The supervisor cannot re-exec itself, so on the next file change it compares the version the project root now installs with its own and, on a difference, stops instead of restarting:
the installed @voltro/cli changed under this voltro dev — stopping instead of restarting running=1.4.0 installed=1.5.0 exitCode=3Exit code 3 is reserved for this case, distinct from a crashed child. Under a restart policy (a pod) the process comes back on the installed version; in a terminal, start voltro dev again. A first boot on a cli the project does not install — a global voltro, a maintainer's workspace binary against a checkout — is only warned about, because it is legitimate.
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.
After such a restart the first process stays as a thin supervisor of the
replacement. It forwards Ctrl+C, kill and a closing terminal or tmux pane
(SIGINT, SIGTERM, SIGHUP) to it, stops it when the supervisor itself loses its
parent, and exits the way it exited — so no replacement is left holding the
port after the terminal is gone.
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.VOLTRO_API_PORTfor an api app,VOLTRO_WEB_PORTfor a web app — the per-kind override, for a project that runs both from one env file.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.
One such value has a known source, and the warning names it: tcp://<ip>:<port>
is what Kubernetes injects as <SERVICE>_PORT for every Service in the pod's
namespace. A Service named voltro-api sets VOLTRO_API_PORT, one named
voltro-dashboard sets VOLTRO_DASHBOARD_PORT — on pods that never asked for
either. Set enableServiceLinks: false on the pod spec, or set the variable
yourself. The same holds for VOLTRO_WEB_PORT and VOLTRO_WORKFLOW_RUNNER_PORT.
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 (or VOLTRO_DASHBOARD_PORT); later instances see it's already up and skip it — so give every app the same value.
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 — and if you have never installed
@voltro/dashboard, you do not have it at all. The boot says so once.
Web routes in CI
For a type: 'web' app, voltro codegen <appDir> writes .framework/routes.generated.ts using the same page discovery and generator as development. It does not boot a server or execute page modules. Run it after installing dependencies and before the web typecheck:
voltro codegen apps/acme/web
pnpm --dir apps/acme/web exec tsc --noEmitKeep the generated directory ignored. Re-running codegen removes deleted route keys and preserves unchanged output without rewriting it. Use its typed routes builders for internal links; a cast-based routeUrl helper cannot prove the destination exists.