File conventions
The dot-suffix file conventions Voltro uses for procedure descriptors, server executors, workflows, agents, schemas, and pages.
Voltro replaces router and registry config with filesystem conventions. Drop a file with the right suffix, the CLI's discovery walker picks it up, and the exports are wired into the runtime on the next boot. No manual registration, no import barrels to maintain.
The api side (apps/*/api/)
| Suffix | What it is | Wired into |
|---|---|---|
*.query.ts |
Browser-safe reactive query descriptor: defineQuery({ name, source, input, output, guards }). |
Typed RPC group + client metadata. |
*.query.server.ts |
Server executor for the matching query descriptor. | Reactive subscription runtime. |
*.mutation.ts |
Browser-safe mutation descriptor: defineMutation({ name, target, input, output, error, guards }). |
Typed RPC group + auto-optimistic metadata. |
*.mutation.server.ts |
Server executor for the matching mutation descriptor. | Transactional mutation runner. |
*.action.ts |
Browser-safe action descriptor: defineAction({ name, input, output, error, guards }). |
Typed RPC group. |
*.action.server.ts |
Server executor for the matching action descriptor. | Non-transactional action runner. |
*.stream.ts |
Browser-safe one-shot stream descriptor: defineStream({ name, input, element, error, guards }). |
Typed streaming RPC group. |
*.stream.server.ts |
Server executor returning an Effect Stream. |
Plain server-to-client element streams. |
*.workflow.tsx |
A durable Effect workflow. Survives restarts. | @effect/workflow runtime. |
*.agent.tsx |
Browser-safe AI agent descriptor: defineAgent({ name, input }). Codegen-typed routes. |
Agent runtime + client types. |
*.agent.server.tsx |
Server agent executor: defineAgentExecutor(descriptor, { system, tools, model, maxSteps }). |
Agent runtime. |
*.tool.tsx |
A tool an agent can call. Schema + handler. | Agent runtime. |
*.webhook.tsx |
Outgoing webhook spec (target, retry, schema). | Webhook delivery worker. |
*.ws.ts |
Raw WebSocket gateway — defineWebSocket({ path, auth, onConnection }) as the default export, for FOREIGN protocols beside the rpc socket. |
Upgrade listener on the api server, both boot paths. |
*.entity.ts |
Database table — one table per file: table() + columns + mixins. Re-exported from a database/index.ts barrel. |
Migrations + the runtime data store. |
*.config.ts |
App-level config (app.config.ts, tsconfig.json, etc.). |
The CLI. |
Procedure descriptors are intentionally separate from server executors. Descriptor files are safe for browser imports and codegen; .server.ts files can import the database, file system, SDK clients, secrets, and other server-only modules. Each descriptor has exactly one matching .server.ts file with the same primitive suffix. Workflows follow the same split: a browser-safe *.workflow.tsx descriptor (importing workflow from @voltro/workflow/define) paired with a *.workflow.server.tsx executor.
The access decision is not optional
guards appears in all four procedure signatures above because dropping a file into the tree puts it on the wire, and a wire-exposed procedure has to say who may call it. Exactly one of three:
guards: [{ scope: 'notes:read' }] // the caller must hold a scope
openAccess: 'public pricing page — reads no caller data' // anyone may call it, and why
internal: true // not on the wire at allA descriptor that declares none of them is refused at boot — by voltro dev, by voltro serve, and by voltro doctor as a preflight. This is the one field a newly-created procedure file is most likely to be missing, and the failure is a boot refusal naming the file rather than a subtle runtime surprise. Full rules, including openAccess's required reason and the per-app security.defaultDeny switch: Authorization.
The browser-safe rule is transitive, and that is where it usually breaks. The codegen pulls every descriptor (and every workflow descriptor) value-level into rpcGroup.generated.ts, which the web client loads — so a descriptor plus everything it imports must stay free of server-only code (node:*, the database handle, @voltro/ai, cluster, plugins, @voltro/protocol/session). The classic mistake is not a literal import 'node:crypto' but a descriptor importing a shared typed-error or helper from a lib/ file that also imports the database — which drags the whole schema graph into the browser bundle. Keep typed errors, Schemas, and pure helpers in files with zero server imports; put DB-backed guards in .server.ts. A leak shows up as the web app fetching hundreds of modules / tens of MB on first load, or crashing with Module "node:crypto" has been externalized for browser compatibility.
Declaring a shared file browser-safe: *.client.ts
A shared lib/ helper can state the rule about itself. Name it *.client.ts (or *.client.tsx) and it declares: I, and everything I transitively import, are browser-safe. voltro dev walks its import graph at boot and refuses to start if the claim is false, printing the chain.
lib/orderErrors.client.ts # I and my imports are browser-safe — checked at boot
lib/orderGuards.server.ts # I may touch the database handle
lib/orderTypes.ts # unmarked: no claim, the graph decidesThis is the mirror of *.server.ts, and it works for the same reason: both declare a permission, which is something an import graph cannot derive. The graph can tell you what a file imports; it can never tell you what a file is allowed to import.
Without the marker the leak is still caught — by the rpcGroup guard — but only once some descriptor happens to reach the file, and the error is a forty-module chain you read backwards to find the one shared file that should never have touched the database. The marker moves the failure to that file, at the moment it is written.
An unmarked file makes no claim, and that is fine: *.client.ts is for the shared files where the mistake is expensive, not a label to sprinkle on everything.
Raw WebSocket gateways: *.ws.ts
A *.ws.ts file's default export mounts a raw WebSocket upgrade path beside the rpc socket — for a protocol the framework does not speak (a Yjs provider, a legacy device fleet). Discovered on both boot paths, voltro dev and voltro serve:
// gateways/collab.ws.ts
import { defineWebSocket } from '@voltro/protocol'
export default defineWebSocket({
path: '/gateways/collab',
auth: 'subject', // REQUIRED, no default — or 'public', a decision you write down
onConnection: ({ send, onMessage, subject }) => {
onMessage((data) => send(data)) // your protocol, your frames
return () => { /* teardown — runs on disconnect, credential expiry, shutdown */ }
},
})auth: 'subject' authenticates through the same chain as rpc/SSR before the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code 4001); every gateway path is origin-checked at upgrade. Two gateways on one path refuse the boot; a plain GET on a gateway path answers 426. App realtime stays subscriptions — full detail under Raw WebSocket gateways.
The web side (apps/*/web/)
| Path | What it is |
|---|---|
src/pages/**/page.tsx |
A page. URL is the file path; [id]/page.tsx -> /:id, [...slug]/page.tsx -> catch-all. |
src/pages/layout.tsx |
Outer layout — wraps every page. |
src/pages/error.tsx |
Error boundary for the whole subtree. |
src/pages/not-found.tsx |
Fallback rendered when no page matches. |
src/pages/loading.tsx |
Pending UI shown while loaders resolve. |
src/pages/(group)/ |
Route group — does not contribute a URL segment, but layout/error files inside still apply. |
src/*.island.tsx |
A client-side hydration island (split chunk). Used in interactive: 'islands' pages. |
src/**/*.collection.ts |
A content collection declaration (defineCollection) — schema-typed markdown/JSON under content/<name>/**. See Content collections. |
src/**/*.consumer.ts |
A queue consumer (defineQueueConsumer, @voltro/plugin-queue) — Schema-decoded, at-least-once, serial per partition. See Queue. |
grpc.manifest.json |
The gRPC field-number manifest (checked in — append-only wire identity; deletes go reserved). See gRPC surface. |
content/<name>/** |
A collection's content files (markdown with frontmatter, or .json for data collections). Read by getCollection/getEntry. |
Each page can opt into a render strategy via two exports:
// src/pages/blog/[slug]/page.tsx
export const renderMode = 'isr' as const // 'static' | 'spa' | 'ssr' | 'isr'
export const interactive = 'islands' as const // 'none' | 'islands' | 'full'renderModecontrols when the HTML is produced (build vs. request).interactivecontrols how much JS ships ('none'strips it all,'full'hydrates the page,'islands'hydrates only.island.tsxfiles).
A page can also declare its query-string contract as a page export:
export const searchParams = Schema.Struct({
page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
})searchParams(aneffect/Schemastruct — every field optional or with a default) types the page's query string:useSearchParams(searchParams)returns the decoded shape, and links built withwithQuerytype-check against it. Details: Pages → Query strings.
Two more page exports change what the framework produces for a route:
export const ogImage = ({ params, loaderData, locale }) => ({ type: 'div', props: { /* satori JSX */ } })
export const intercept = { from: '/photos' }ogImagedeclares the page'sog:imageas a satori JSX template.staticpages render the PNG at BUILD time intodist/assets/og/;ssrpages render it on demand over a signed route. Theog:image/twitter:image/twitter:cardtags are injected automatically unless your ownmetaalready sets them. A declared font is REQUIRED (there is no bundled default), and anssrroute exporting it needsVOLTRO_OG_SECRET—voltro startrefuses the boot otherwise. Details: Loaders and meta → OG images.interceptmakes the page an intercepting route:fromnames one or more ROUTE PATTERNS ('/photos',['/photos', '/albums/[id]']), and a soft navigation arriving from one of them renders this page as an overlay above the still-mounted origin. Every hard load — and a soft navigation from anywhere else — renders it standalone. Details: Intercepting routes.
Discovery in practice
apps/acme/api/
├── queries/
│ ├── notes.list.query.ts
│ └── notes.list.query.server.ts -> notes.list query
├── mutations/
│ ├── notes.create.mutation.ts
│ ├── notes.create.mutation.server.ts -> notes.create mutation
│ ├── notes.update.mutation.ts
│ └── notes.update.mutation.server.ts -> notes.update mutation
├── streams/
│ ├── ticker.stream.ts
│ └── ticker.stream.server.ts -> ticker stream
├── workflows/
│ └── notes.summarise.workflow.tsx -> notes.summarise workflow
└── database/
├── users.entity.ts -> users table
├── todos.entity.ts -> todos table
└── index.ts -> databaseHandle + re-exportsvoltro dev writes rpcGroup.generated.ts beside the api source. It imports descriptor files only, lifts them into an RpcGroup, and exports descriptor metadata for the client. Web apps consume the mounted api with useSubscription, useMutation, useAction, or useAgentStream from @voltro/client.
Anti-patterns
- Don't put two procedure descriptors with the same
name. The CLI fails the boot — fix the name collision. - Don't import
.server.tsfiles from your web app. Use the generated client. Importing the server module straight into the browser bundle leaks server-only deps (Postgres driver, secret keys). - Don't move generated files in
.framework/into the user source tree. They're disposable; the CLI rewrites them on every boot.