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 (or VOLTRO_DASHBOARD_PORT) — when it is installed (pnpm add -D --save-exact @voltro/dashboard — the stability contract asks for exact pins, and pnpm add writes a caret range without the flag), and unless VOLTRO_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

  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 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 awaitSignal offers 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/logger record this page produced AND every console.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.info are 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 / 100vh and media queries, and the window's scroll. While the page is pushed it scrolls inside <body>, so window.scrollY stays 0 and a scroll listener on window sees 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 and kubectl port-forward all arrive this way. A reverse proxy on the same host (Caddy, an ingress sidecar, a tunnel daemon) also connects from 127.0.0.1 but adds X-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's Host through, and a Host that is not localhost, a *.localhost name, 127.x.x.x or [::1] does not count as this machine either;
  • a client network you listed — set VOLTRO_INSPECT_TRUSTED_CLIENTS to the networks your team connects from (addresses, CIDR ranges, or private), for a deployed dev environment where a per-browser sign-in is the wrong default. Behind an ingress, also declare the ingress in VOLTRO_TRUSTED_PROXIES: the client address is read from X-Forwarded-For only 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 (private covers 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 without X-Forwarded-For (or the header named in VOLTRO_CLIENT_ADDRESS_HEADER) identifies nobody and is not trusted by network. A list containing 0.0.0.0/0, ::/0 or *, or set beside VOLTRO_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 from POST /_voltro/devtools/login, valid for 12 hours. A locked server trusts sessions only — its own loopback included — because a plain TCP forwarder (socat, ssh -R with 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:

  1. Leave apis.<name>.url unset and set VOLTRO_API_ORIGIN_<NAME> (or VOLTRO_API_ORIGIN) on the web pod to the api's internal origin, for example http://voltro-api:3335. An origin an operator named there (or listed in VOLTRO_DASHBOARD_APPS) is configured: the proxy may send it a token. A remote origin that only comes from app.config.ts never gets one.
  2. Give the web pod the api's VOLTRO_INSPECT_TOKEN — or list the api's origin in VOLTRO_DASHBOARD_APPS with its token and writeToken, which also carries the write factor overlay actions need — and list the api's host in the api's VOLTRO_INSPECT_ALLOWED_HOSTS (here voltro-api) — its DNS-rebinding guard refuses other names.
  3. Decide who reads through the ingress: list your team's networks in VOLTRO_INSPECT_TRUSTED_CLIENTS with the ingress in VOLTRO_TRUSTED_PROXIES (no sign-in), and/or set VOLTRO_DASHBOARD_TOKEN on the web pod (one sign-in per browser in the overlay). With neither, only kubectl port-forward to 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 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.

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 + reloading

This 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 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 (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.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 cli=1.5.0

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

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

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:

  1. VOLTRO_DASHBOARD_PORT, and only in the auto-launched dashboard process.
  2. VOLTRO_API_PORT for an api app, VOLTRO_WEB_PORT for a web app — the per-kind override, for a project that runs both from one env file.
  3. PORT from the environment.
  4. --port <n> (voltro serve, voltro dormancy).
  5. port: in the app's app.config.ts.
  6. 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.

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 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 — 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 --noEmit

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