# Reference

> The client-side hook surface, grouped by purpose.



---

<!-- source: en/reference/hooks-overview.md -->
## React hooks — overview

_The client-side hook surface, grouped by purpose._

The web side of a Voltro app talks to API apps through React hooks. Data hooks are keyed by API name and RPC tag; routing hooks are provided by `@voltro/web`.

App code should reach for the **typed binding** rather than the tag-taking hooks directly: [`createHooks(apiName)`](/docs/reference/hooks-data) takes the api's generated `AppProcedures` map and returns `useSubscription` / `useMutation` / `useAction` whose tag is a literal union and whose input and output types are inferred — so a typo'd tag is a compile error and the result needs no annotation. Bind it once per api in `src/lib/api.ts`. The tag-taking forms documented below are the primitive underneath, for code that only learns the tag at runtime.

## Data Hooks

| Hook | Purpose |
|---|---|
| [`createHooks`](/docs/reference/hooks-data) | Bind the typed hook surface for one api — the recommended app-facing entry point. |
| [`useSubscription`](/docs/reference/hooks-data) | Subscribe to a reactive query (`*.query.ts`). |
| [`useMutation`](/docs/reference/hooks-data) | Run an atomic write (`*.mutation.ts`). |
| [`useAction`](/docs/reference/hooks-data) | Run a unary non-transactional action (`*.action.ts`). |
| [`useWorkflow`](/docs/reference/hooks-data) | Start, cancel, resume, or signal a durable workflow (`*.workflow.tsx`). |
| [`useWorkflowSignal`](/docs/reference/hooks-data) | Focused signal sender, for approval buttons that don't also start workflows. |
| [`useWorkflowUpdate`](/docs/reference/hooks-data) | Focused tracked update — waits for the workflow handler's result. |
| [`useWorkflowRun`](/docs/reference/hooks-data) | Subscribe to one workflow run's reactive status row. |
| [`useWorkflowRuns`](/docs/reference/hooks-data) | Subscribe to a bounded/filterable workflow run list. |
| [`useWorkflowRunSteps`](/docs/reference/hooks-data) | Subscribe to one run's checkpointed step timeline. |
| [`useWorkflowRunEvents`](/docs/reference/hooks-data) | Subscribe to one run's lifecycle/timer/signal event timeline. |
| [`useWorkflowDomainEvents`](/docs/reference/hooks-data) | Subscribe to the domain events a workflow emitted (the business-event timeline). |
| [`useWorkflowEventDeliveries`](/docs/reference/hooks-data) | Subscribe to delivery attempts/outcomes for those emitted events. |
| [`useWorkflowRunState`](/docs/ui/reactive-components) | Aggregates run + steps + events into one `{ status, currentStep, waitingFor, … }` plus cancel/resume/signal/update. |
| [`useAgentStream`](/docs/reference/hooks-data) | Consume a one-shot element stream (`*.stream.ts`). |
| [`useAgent`](/docs/reference/hooks-data) | Convenience wrapper for transient AI streams. |

## Routing Hooks

| Hook | Purpose |
|---|---|
| [`useLocation`](/docs/reference/hooks-routing) | Current pathname and route state. |
| [`useParams`](/docs/reference/hooks-routing) | URL params from `[name]` segments. |
| [`useNavigate`](/docs/reference/hooks-routing) | Programmatic navigation. |
| [`usePrefetch`](/docs/reference/hooks-routing) | Trigger loader-data prefetch. |
| [`useLoaderData`](/docs/reference/hooks-routing) | Page loader output. |

## Server / Context Hooks

| Hook | Purpose |
|---|---|
| [`useServerRequest`](/docs/reference/hooks-server) | Request snapshot during SSR and hydration-sensitive client code. |

## Schema-driven UI Hooks

The headless primitives that derive UI from a descriptor's Schema. **Reach for
these before hand-rolling a form, a table, or a picker** — full guide in
[Schema-driven UI](/docs/ui/overview).

| Hook | Purpose |
|---|---|
| [`useFormBinding`](/docs/ui/forms-and-tables) | Bind a form to a MUTATION — fields + validation from its input Schema; a server `ValidationError({ field })` routes to that field. |
| [`useDataTable`](/docs/ui/forms-and-tables) | Bind a table to a QUERY — live rows, columns derived from the output Schema, sort/filter/pagination. |
| [`useQueryFilters`](/docs/ui/forms-and-tables) | Filter controls derived from a query's INPUT Schema (the read-side mirror of a form). |
| [`useQueryField`](/docs/ui/forms-and-tables) | Query-bound picker — a debounced search term drives a live subscription. |
| [`useFormSkeleton`](/docs/ui/client-utilities/use-skeletons) / [`useTableSkeleton`](/docs/ui/client-utilities/use-skeletons) | Placeholders shaped like the REAL data, from the same Schema. |
| [`useAsyncValidation`](/docs/ui/client-utilities/use-async-validation) | Live server-side validation (uniqueness, cross-row) over a query binding. |
| [`useDebounced`](/docs/ui/client-utilities/use-debounced) | Debounce a value (search, filter, validation input). |
| [`useRecord`](/docs/ui/client-utilities/use-record) | One live record from a "get" query, normalized (array → first row). |

## Files, Permissions, and Client Utilities

| Hook | Purpose |
|---|---|
| [`useUpload`](/docs/plugins/storage) | File upload with progress + cancel, on every storage provider. Not base64 → action. |
| [`useCan`](/docs/ui/client-utilities/use-can) | Scope/RBAC UI gate, over `<PermissionProvider>`. Lives in `@voltro/client` — scopes are a framework concept, so gating a button needs no rbac dependency. |
| [`useCanAny`](/docs/ui/client-utilities/use-permissions) | OR variant of `useCan` — true when the subject holds AT LEAST ONE of the required scopes. |
| [`usePermissions`](/docs/ui/client-utilities/use-permissions) / `<PermissionProvider>` | The current subject's scope set, fed once from your session query — the source `useCan` reads. Gates UI on the SAME scope strings the server checks. |
| [`useResourceCan`](/docs/ui/client-utilities/use-permissions) / `useResourceCans` | Per-RESOURCE (ReBAC) gate, reactive — one resource or many in a single subscription. |
| [`useDerived`](/docs/ui/client-utilities/use-derived) | Dependency-tracked derived value from reactive sources. Replaces hand-maintained `useMemo` dep arrays. |
| [`useWindowedSubscription`](/docs/ui/client-utilities/use-windowed-subscription) | Subscribe to the VISIBLE window of a huge list, not the whole table. |
| [`useOutbox`](/docs/ui/client-utilities/use-outbox) | Queue mutations offline, replay in order on reconnect. |
| [`useUndoLog`](/docs/ui/client-utilities/use-undo) / `useUndo` | Client controller for the server-persisted undo stack. |
| [`usePreview`](/docs/ui/client-utilities/use-preview) | Mutation dry-run — run the real handler in a rolled-back transaction. |
| [`useProvenance`](/docs/ui/client-utilities/use-provenance) | "Why is this value here?" — lineage lookup for a field. |
| [`useOnRpcError`](/docs/ui/client-utilities/use-on-rpc-error) / `reportClientError` | Subscribe to the rpc error bus; report a client error to the server. |
| [`useTracking`](/docs/ui/client-utilities/use-tracking) | Fire mount/unmount + interaction tracking events. |
| [`useCapabilityManifest`](/docs/ui/client-utilities/use-capability-manifest) | The api's capability manifest (procedures + tables + schemas), fetched once. |
| [`useRefreshSubscriptions`](/docs/ui/client-utilities/use-refresh-subscriptions) | Force-refresh live subscriptions (e.g. after an out-of-band change). |

## AI Hooks

| Hook | Purpose |
|---|---|
| [`useAgentChat`](/docs/ui/reactive-components) | Full chat surface over an `*.agent.tsx` (messages + send + streaming). |
| [`useResumableAgentStream`](/docs/ai/streaming) | Agent stream that survives reload/reconnect. |
| [`useDataCopilot`](/docs/ai/data-copilot) | Bind a data-copilot action by api name + tag. |

## Where To Read Next

- [Data hooks](/docs/reference/hooks-data)
- [Routing hooks](/docs/reference/hooks-routing)
- [Server hooks](/docs/reference/hooks-server)
- [Runtime context](/docs/reference/runtime-context)



---

<!-- source: en/reference/hooks-data.md -->
## Data hooks

_useSubscription, useMutation, useAction, useWorkflow, workflow status hooks, useAgentStream, and useAgent._

Data hooks from `@voltro/client` are keyed by **api name** plus **RPC tag**. The tags come from descriptors discovered in the API app.

## `createHooks(apiName)` — the typed hook binding

`createHooks` turns the api's generated procedure map into hooks whose **RPC tag is a literal union** and whose **input and output types are inferred**. It is the recommended way to call an api from app code.

Codegen emits `AppProcedures` into the api's `rpcGroup.generated.ts` — a type-level map of every tag (yours and every plugin's) to the descriptor behind it. Bind it once, at module scope, next to the rest of your api glue:

```ts
// src/lib/api.ts — one file, one line, once per app
import { createHooks } from '@voltro/client'
import type { AppProcedures } from '@app/api/rpcGroup'

export const { useSubscription, useMutation, useAction } = createHooks<AppProcedures>('app')
```

`'app'` is the key this web app gave the api in `app.config.ts` → `apis`. It is now spelled exactly once per app instead of once per call site:

```tsx
import { useSubscription, useMutation } from '../lib/api'

const { data } = useSubscription('notes.list')   // ReadonlyArray<Note>, inferred
const create = useMutation('notes.create')       // input + output inferred
```

What the compiler catches that it could not before:

| Mistake | Before | Now |
|---|---|---|
| Typo in the tag (`'notes.lst'`) | Runtime dev-console error | Compile error |
| Wrong hook for the kind (a mutation passed to `useSubscription`) | Runtime `console.error` | Compile error |
| Missing a required input field | Request sent with `undefined` | Compile error |
| Wrongly-shaped input | Server-side decode failure | Compile error |
| Result type annotation disagreeing with the server | Never detected | Impossible — there is no annotation |

The result keeps every narrowing rule of the untyped hook: `loading` still discriminates `data`, `fallback` / `initialSnapshot` still remove the branch, and only a **dynamic** `skip` adds the `idle` state.

Row types include the auto-optimistic marker the client adds, so `row.optimistic` type-checks on a live list without a hand-written row mirror.

**Destructure the result** — do not export the object and call `api.useSubscription(...)`. `react-hooks/rules-of-hooks` only recognises a member call as a hook when the object is PascalCase, so a lowercase namespace silently switches off rules-of-hooks and exhaustive-deps at every call site.

`import type` is erased at build time, so the binding adds nothing to the browser bundle.

**`rpcGroup.generated.ts` is written by codegen**, so a tree that has never booted does not have it yet and `tsc` reports `Cannot find module '@app/api/rpcGroup'`. `voltro dev` generates it on boot; run `voltro codegen` once for a fresh clone or a CI job that only typechecks. The scaffolded api templates do this in their own `typecheck` script, and `pnpm -r` runs the api before anything that depends on it.

The tag-taking hooks below are the primitive underneath. Reach for them when the tag is only known at runtime — plugin web bindings and libraries shipped against an unknown app — not in app code.

## `useSubscription(apiName, rpcTag, input?, options?)`

Subscribes to a reactive `defineQuery` RPC.

```tsx
const { data, error, revision, pendingPatches } = useSubscription(
  'app',
  'notes.list',
  { archived: false },
)
```

Returns:

| Field | Meaning |
|---|---|
| `data` | Latest typed query output; `undefined` while `loading` is `true`. |
| `loading` | `true` until the first snapshot arrives. Discriminates the result — see below. |
| `isEmpty` | The snapshot that arrived is empty. Always `false` while `loading`. |
| `error` | Subscription setup error, if no snapshot could be delivered. |
| `revision` | Server revision counter. |
| `emittedAt` | Timestamp for the latest server delta. |
| `pendingPatches` | Number of active optimistic patches applied to this cache entry. |

The result type `SubscriptionState<T>` is a **discriminated union on `loading`**:

```ts
type SubscriptionState<T> =
  | { loading: true;  data: undefined; isEmpty: false }
  | { loading: false; data: T;         isEmpty: boolean }
// both members also carry revision, emittedAt, error and pendingPatches
```

So `loading` is a type guard, not a flag beside `data` — narrow on it and `data`
is `T`, with no `?? []` and no `!`:

```tsx
const { data, loading } = useSubscription<Team[]>('app', 'teams.list')
if (loading) return <Skeleton/>
return <TeamsTable teams={data}/>   // data is Team[]
```

Because it is a union, an `interface X extends SubscriptionState<...>` does not
compile — TypeScript cannot extend a union. Intersect instead:

```ts
type TeamsState = SubscriptionState<ReadonlyArray<Team>> & { readonly canEdit: boolean }
```

`loading` means **no data has arrived yet**, not "the subscription is still
warming up". A cold-start failure leaves `loading` true and sets `error`, so a
component branching on `loading` alone renders a skeleton forever — check
`error`.

Use `{ skip }` to defer until inputs are ready:

```tsx
const { data } = useSubscription(
  'app',
  'messages.list',
  { channelId },
  { skip: channelId === undefined },
)
```

## `useMutation(apiName, rpcTag)`

Calls a `defineMutation` RPC.

```tsx
const create = useMutation<{ title: string }, { id: string }>('app', 'notes.create')

await create.mutate({ title: 'Hello' })
```

Returns:

| Field | Meaning |
|---|---|
| `mutate(input, options?)` | Calls the mutation and resolves the typed output. `options` is `{ onSuccess, onError, notify }` — see [Mutations](/docs/data/mutations). |
| `pending` | `true` while a call is in flight. |
| `error` | Last failure, or `undefined`. |
| `data` | Last successful result, or `undefined`. |
| `withOptimistic(fn)` | Override descriptor-derived optimistic patches. |
| `withoutOptimistic()` | Disable optimistic patches for this mutation handle. |

Custom optimistic example:

```tsx
const create = useMutation('app', 'notes.create').withOptimistic((cache, input) => {
  cache.forTag<ReadonlyArray<{ id: string; title: string }>>('notes.list', (rows) => [
    { id: `temp:${Date.now()}`, title: String(input.title) },
    ...rows,
  ])
})
```

## `useAction(apiName, rpcTag)`

Calls a `defineAction` RPC. Actions are unary like mutations, but the client has no optimistic/cache surface for them.

```tsx
const invite = useAction<{ email: string }, { ok: boolean }>('app', 'invites.send')

await invite.run({ email })
```

Returns `run`, `pending`, `error`, and `lastResult`.

`run` takes the same options bag as `useMutation`'s `mutate` —
`{ onSuccess, onError, notify }`:

```tsx
const invite = useAction('app', 'invites.send')

await invite.run({ email }, {
  onSuccess: (out) => toast.success(`sent ${out.id}`),
  onError:   (e)   => toast.error(readError(e)),
})
```

Same load-bearing semantic: supplying an error handler (`onError` **or**
`notify.error`) marks the failure **handled**, so `run` resolves with `undefined`
instead of rejecting — that is what removes the `try/catch`. With no options it
rejects exactly as before, so unhandled failures stay loud.

**The callback form is for single-shot writes.** A loop or a multi-step sequence
relies on the promise *throwing* to stop; once the failure is handled the promise
resolves and the loop keeps going. Sequenced writes want the bare `run(input)`
plus a real `try/catch` — see [Actions](/docs/data/actions).

## `useWorkflow(apiName, workflowName)`

Starts and controls a discovered `*.workflow.tsx`. Starting returns a `WorkflowRunHandle` immediately; it does not wait for the workflow's success payload.

```tsx
const summarise = useWorkflow<{ noteId: string }>('app', 'notes.summarise')

const run = await summarise.start({ noteId })
```

`voltro codegen` also emits workflow type maps from your `*.workflow.tsx`
definitions:

```tsx
import type {
  WorkflowMessages,
  WorkflowPayloads,
  WorkflowResults,
} from '@app/api/rpcGroup.generated'

const summarise = useWorkflow<
  WorkflowPayloads['notes.summarise'],
  WorkflowMessages['notes.summarise']
>('app', 'notes.summarise')

type SummaryResult = WorkflowResults['notes.summarise']
```

Returns:

| Field | Meaning |
|---|---|
| `start(payload)` | Starts the durable workflow and resolves a run handle. |
| `cancel({ workflowName, executionId })` | Cancels a running execution. |
| `resume({ workflowName, executionId })` | Resumes a suspended execution. |
| `signal({ id }, signalName, payload?)` | Sends an external signal to a run id or execution id. |
| `update({ id }, updateName, payload?, options?)` | Sends a tracked update and resolves with the workflow handler result. |
| `pending` | `true` while `start(...)` is in flight. |
| `error` | Last start failure, or `undefined`. |
| `data` | Last returned run handle, or `undefined`. |

## `useWorkflowSignal(apiName)`

Focused helper for signal buttons and approval forms that do not also start workflows:

```tsx
const signal = useWorkflowSignal('app')

await signal.signal({ id: runId }, 'approval', { approved: true })
```

It returns `signal(...)`, `pending`, `error`, and the last `{ eventId }`.

## `useWorkflowUpdate(apiName)`

Focused helper for tracked workflow updates. Unlike signals, updates wait for the workflow's `awaitUpdate(...)` handler to validate and return a result:

```tsx
const approve = useWorkflowUpdate('app')

const result = await approve.update(
  { id: runId },
  'approve',
  { decision: true },
  { timeoutMs: 30_000 },
)
```

It returns `update(...)`, `pending`, `error`, and the last `{ eventId, updateId, completedEventId, result }`.

## `useWorkflowRun(apiName, id)`

Subscribes to Voltro's built-in reactive workflow-run query. `id` can be the durable `executionId` returned by `useWorkflow().start(...)` or the `_voltro_workflow_runs.id` from inspection data.

```tsx
const { run, data, error } = useWorkflowRun('app', runId)
```

`run` is the first row from the built-in query. It updates through the normal reactivity engine as `_voltro_workflow_runs` changes, so workflow status UIs do not need polling.

## `useWorkflowRuns(apiName, filters?, options?)`

Subscribes to a bounded workflow-run list:

```tsx
const { runs } = useWorkflowRuns('app', {
  tag: 'notes.summarise',
  status: 'running',
  limit: 25,
})
```

Filters are optional. `limit` defaults to `100` and is capped by the server, so app-level job centers do not accidentally subscribe to the entire run history.

## `useWorkflowRunSteps(apiName, runId)`

Subscribes to the step timeline for one `_voltro_workflow_runs.id`:

```tsx
const { steps } = useWorkflowRunSteps('app', liveRun?.id)
```

Step rows update reactively as checkpointed `step({...})` activities start, succeed, or fail.

## `useWorkflowRunEvents(apiName, runId)`

Subscribes to the event timeline for one `_voltro_workflow_runs.id`:

```tsx
const { events } = useWorkflowRunEvents('app', liveRun?.id)
```

Events include lifecycle changes, timers, signals, and updates recorded by the workflow runtime.

`useWorkflowEvents(apiName, runId)` is a shorter alias for the same hook.

## `useWorkflowDomainEvents(apiName, filters?, options?)`

Subscribes to the **domain events** an app emitted through `ctx.events.publish(...)` — the business-event log behind [event triggers](/docs/workflows/event-triggers), not one run's internal timeline.

```tsx
const { events } = useWorkflowDomainEvents('app', { name: 'order.paid', limit: 50 })
```

Both filters are optional: `name` narrows to one event name, `limit` defaults to `100` and is clamped to `500` by the server. Rows arrive newest-first (by `occurredAt`), and each carries `id`, `name`, `payload`, `source`, `subject`, `traceId`, and `occurredAt`. The third argument is the standard `SubscriptionOptions` (e.g. `{ skip }`).

Alongside `events`, the hook returns the normal subscription fields (`data`, `error`, `revision`, …).

## `useWorkflowEventDeliveries(apiName, eventId)`

Subscribes to the **fan-out** of one emitted domain event: one row per trigger the event was routed to, so you can see which workflows a single `emit(...)` actually started.

```tsx
const { deliveries } = useWorkflowEventDeliveries('app', selectedEvent?.id)

deliveries.map((d) => `${d.workflowName}: ${d.status}`)
```

The subscription is skipped while `eventId` is `undefined`, so it pairs directly with a row selected out of `useWorkflowDomainEvents`. Rows are ordered oldest-first and carry `eventId`, `eventName`, `triggerId`, `workflowName`, `executionId`, `idempotencyKey`, `skipped`, `errorMessage`, `createdAt`, `completedAt`, and a `status` of `'starting'`, `'started'`, `'skipped'`, or `'failed'`.

A `skipped` delivery is the normal outcome when a trigger's `filter` returned false or its `idempotencyKey` had already been seen — it is not a failure. `errorMessage` is set only on `'failed'`.

## `useAgentStream(apiName, rpcTag)`

Consumes a `defineStream` RPC. Despite the name, this hook is not limited to AI agents; it handles any one-shot element stream.

```tsx
const ticker = useAgentStream<{ price: number }>('app', 'ticker.watch')

ticker.start({ symbol: 'BTC' })
ticker.cancel()
```

Returns:

| Field | Meaning |
|---|---|
| `events` | Elements received so far, in order. |
| `status` | `'idle'`, `'streaming'`, `'done'`, or `'error'`. |
| `error` | Failure when `status === 'error'`. |
| `start(input?)` | Starts a new run and clears old events. |
| `cancel()` | Interrupts the in-flight run. |

## `useAgent(apiName, rpcTag)`

Ergonomic wrapper over `useAgentStream` for transient AI chat streams. It derives `tokens` from token events and folds completed turns into `history`.

```tsx
const support = useAgent('app', 'support.run')

support.send({ message: 'Help me', history: support.history })
support.cancel()
```

For durable chat generated by `defineAgent`, use the normal pair: `useSubscription('app', '<name>.messages', input)` plus `useAction('app', '<name>.send')`.

## Connection Lifecycle

All hooks share the API WebSocket. Query subscriptions resubscribe after reconnect and get fresh snapshots. In-flight unary calls reject on disconnect. In-flight streams end with an error and must be started again.

## See Also

- [Queries](/docs/data/queries)
- [Mutations](/docs/data/mutations)
- [Actions](/docs/data/actions)
- [Streams](/docs/data/streams)
- [Workflows](/docs/workflows/overview)



---

<!-- source: en/reference/hooks-routing.md -->
## Routing hooks

_useLocation, useParams, useNavigate, usePrefetch, useLoaderData — navigating + reading URL state._

The routing hooks from `@voltro/web`. They read URL state, trigger navigation, and access loader data.

## `useLocation()`

The current pathname (just the path; query string is separate).

```ts
import { useLocation } from '@voltro/web'

const pathname = useLocation()
// '/docs/intro/getting-started'
```

Updates on every navigation. Use for:

- Active-link styling
- "Did the URL change?" effect dependencies
- Conditional rendering based on path

```tsx
const Nav = () => {
  const pathname = useLocation()
  return (
    <ul>
      <li><Link to="/" className={pathname === '/' ? 'active' : ''}>Home</Link></li>
      <li><Link to="/about" className={pathname === '/about' ? 'active' : ''}>About</Link></li>
    </ul>
  )
}
```

For "active if URL starts with prefix":

```tsx
className={pathname.startsWith('/dashboard') ? 'active' : ''}
```

## `useParams<T>()`

URL params from `[name]` segments. Typed via the generic.

```tsx
// src/pages/users/[id]/page.tsx
import { useParams } from '@voltro/web'

const { id } = useParams<{ id: string }>()
```

For catch-all queries:

```tsx
// src/pages/docs/[...slug]/page.tsx
const { slug } = useParams<{ slug: string }>()
// /docs/intro/getting-started → slug = "intro/getting-started"
```

For multi-segment dynamic paths:

```tsx
// src/pages/orgs/[orgId]/projects/[projectId]/page.tsx
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
```

Params are always strings — convert numbers explicitly:

```ts
const id = Number(params.id)
if (Number.isNaN(id)) throw new NotFoundError()
```

## `useNavigate()`

Programmatic navigation.

```tsx
import { useNavigate } from '@voltro/web'

const SignOutButton = () => {
  const navigate = useNavigate()
  const onSignOut = async () => {
    await fetch('/auth/signout', { method: 'POST' })
    navigate('/login')
  }
  return <button onClick={onSignOut}>Sign out</button>
}
```

Returns a function `(to: string, options?) => void`.

| Option | Notes |
|---|---|
| `replace: true` | Replace the history entry (no back-button entry). |
| `scroll: false` | Don't scroll to top after navigation. |
| `transition: true / false` | Run (or suppress) this navigation's swap through `document.startViewTransition`, overriding the app-wide `router.viewTransitions` default. `<Link transition>` is the declarative mirror. See [View transitions](/docs/routing/navigation#view-transitions). |

`navigate` takes a path string only — there is no numeric history overload. For back / forward, reach for the browser API:

```ts
window.history.back()      // back
window.history.forward()   // forward
```

## `useBlocker()`

Hold a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.

```tsx
import { useBlocker } from '@voltro/web'

const blocker = useBlocker(form.isDirty)   // boolean or a predicate
// …
{blocker.blocked && (
  <ConfirmDialog onConfirm={blocker.retry} onCancel={blocker.reset} />
)}
```

Pass `true`/`false` or a predicate `({ to, opts }) => boolean` (to allow some destinations). When a navigation is held, the hook returns `{ blocked: true, to, retry, reset }`: `retry()` proceeds, `reset()` cancels. A full-page unload also triggers the browser's native prompt while any blocker is active. See [Navigation](/docs/routing/navigation) for the full example.

## `useSearchParams()` + `useSetSearchParams()`

Read the query string. Two overloads, both SSR-aware (the request URL on the server, `window.location.search` on the client):

- `useSearchParams()` — the raw `URLSearchParams`, for routes without a schema.
- `useSearchParams(searchParams)` — pass the page's own `searchParams` schema export to get the decoded, typed shape. Defaults applied; an invalid query falls back to the defaults instead of crashing.

```tsx
import { Schema } from 'effect'
import { useSearchParams } from '@voltro/web'

export const searchParams = Schema.Struct({
  tab:  Schema.optionalWith(Schema.String, { default: () => 'overview' }),
  page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
})

const { tab, page } = useSearchParams(searchParams)   // tab: string · page: number
const raw = useSearchParams()                         // URLSearchParams (schema-less routes)
```

Write it with `useSetSearchParams()` — the setter updates the query via `navigate`, so readers re-render immediately:

```tsx
import { useSetSearchParams } from '@voltro/web'

const setParams = useSetSearchParams()
setParams({ tab: 'members' })                        // set (default: replace)
setParams((p) => { p.set('page', '2'); return p })   // patch one param
setParams({ page: '2' }, { push: true })             // distinct history entry
```

Writes default to a history replace; pass `{ push: true }` for a Back entry or `{ scroll: false }` to keep scroll. See [Navigation](/docs/routing/navigation#reading--writing-search-params).

`useSetSearchParams(searchParams)` — pass the schema to get the **typed** setter. Object form replaces the query (a left-out field decodes to its default on the next read); the updater form receives the current **decoded** params, so a merge is an explicit spread:

```tsx
const setTyped = useSetSearchParams(searchParams)
setTyped({ page: 2 })                          // replaces → ?page=2
setTyped((p) => ({ ...p, page: p.page + 1 }))  // keeps every other param — typed merge
```

## Typed `withQuery()`

Not a hook, but the link-side half of the same contract: for a route whose page exports a `searchParams` schema, the generated `routes` builder brands the URL with the schema's shape (through a type-only import — no page code enters the routes module), and `withQuery` type-checks the params against it — a misspelt key or a wrong value type is a compile error:

```tsx
import { withQuery } from '@voltro/web'
import { routes } from './.framework/routes'

withQuery(routes['/notes'](), { page: 2 })     // OK — typed against the schema
// withQuery(routes['/notes'](), { pgae: 2 })  // compile error (unknown key)
```

The encode is canonical: strings pass through, numbers/booleans via `String()`, arrays as repeated keys, `undefined` omitted; a `Date` (or any object) is refused loudly — declare the field as a string/number transform in the schema instead. See [Navigation → typed `withQuery`](/docs/routing/navigation#typed-withquery).

## `usePrefetch()`

Trigger loader-data prefetch on hover / focus. Wired automatically by `<Link prefetch />`; export only useful for custom triggers.

```tsx
import { usePrefetch } from '@voltro/web'

const Card = ({ id }) => {
  const prefetch = usePrefetch()
  return (
    <article onMouseEnter={() => prefetch(`/notes/${id}`)}>
      {/* …card body, no Link inside */}
    </article>
  )
}
```

Idempotent — multiple calls for the same path fire one loader. The prefetched result is held in the loader cache until it's consumed by the actual navigation (or invalidated on an error reset).

## `useLoaderData<T>()`

Page's loader output, typed.

```tsx
// src/pages/notes/[id]/page.tsx
import { useLoaderData } from '@voltro/web'

interface Note {
  id: string
  title: string
}

export const loader = async ({ params }): Promise<Note> => {
  return await fetchNote(params.id)
}

export default function NotePage(): ReactNode {
  const note = useLoaderData<Note>()
  return <article><h1>{note.title}</h1></article>
}
```

Available in:

- The page component itself
- Any layout in the page's chain
- Any descendant of the layout

Returns `null` on pages without a `loader`. The generic narrows the type.

Precisely, it returns `LoaderData<T>`. For every ordinary loader that IS `T`. For
a loader that returned `defer()`, `LoaderData<T>` flattens the two buckets into
one object — eager fields as values, deferred fields as `Promise<T>` — so the
compiler tells you which fields have to be rendered through
[`<Await>`](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await):

```tsx
export const loader = async ({ query }) => defer(
  { user: await query('users.me') },
  { report: query('reports.quarterly') },
)

// user: User            report: Promise<Report>
const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()
```

If you wrap this hook in your own generic helper, propagate the mapped type
(`<D,>(): LoaderData<D> => useLoaderData<D>()`) — `LoaderData<D>` is not
assignable to a bare type parameter `D`.

See [Loaders & meta](/docs/routing/loaders-and-meta) for the server-side counterpart.

## Compositions

```tsx
// "go to next page after a delay"
const navigate = useNavigate()
useEffect(() => {
  const t = setTimeout(() => navigate('/welcome'), 3000)
  return () => clearTimeout(t)
}, [navigate])
```

```tsx
// "save the URL the user came from for after-signin redirect"
const pathname = useLocation()
const fromUrl = useMemo(() => pathname, [])     // capture at mount, not every render
```

```tsx
// "active link with hover-prefetch"
const Link = ({ to, children }) => {
  const pathname = useLocation()
  const prefetch = usePrefetch()
  const active = pathname === to
  return (
    <a
      href={to}
      onMouseEnter={() => prefetch(to)}
      className={active ? 'active' : ''}
    >
      {children}
    </a>
  )
}
// (You don't usually write this — `<Link>` from `@voltro/web` does it all.)
```

## Anti-patterns

- **`window.location.href = '/foo'` for internal nav.** Full reload — defeats the SPA. Use `useNavigate`.
- **Reading `params` outside the page tree.** Layouts above the page CAN read params (they share the chain), but components imported as siblings can't. Pass params down explicitly.
- **`useEffect(() => navigate(...), [])` for default redirects.** Triggers a flash of the original page. Do redirects in the LOADER instead (`throw new RedirectError(...)`).

## See also

- [Navigation](/docs/routing/navigation) — `<Link>` + prefetch patterns
- [Loaders & meta](/docs/routing/loaders-and-meta) — what populates `useLoaderData`



---

<!-- source: en/reference/hooks-server.md -->
## Server hooks

_useServerRequest — the SSR-only escape hatch for reading cookies, headers, and the URL during render._

The only true "server hook" in Voltro is `useServerRequest`. It exposes the request snapshot during SSR so layouts + pages can read cookies, headers, and the URL during the render pass.

## `useServerRequest()`

```tsx
import { useServerRequest } from '@voltro/web'

const req = useServerRequest()
// req is { cookies, headers, url } on the server, null on the client
```

Returns:

```ts
interface ServerRequest {
  readonly cookies: Readonly<Record<string, string>>
  readonly headers: Readonly<Record<string, string>>
  readonly url:     string   // includes query string
}
```

Or `null` on the client (after hydration).

## What it's for

The classic use case: decode the session cookie during SSR so the layout renders the right subject-aware UI:

```tsx
// src/pages/layout.tsx
import { useServerRequest } from '@voltro/web'
import { SubjectProvider } from '@voltro/plugin-auth/web'
import { decodeSubjectFromRequest } from './lib/auth'

export default function Layout({ children }) {
  const req = useServerRequest()
  const subject = decodeSubjectFromRequest(req)
  return <SubjectProvider subject={subject}>{children}</SubjectProvider>
}
```

`decodeSubjectFromRequest` is app code (it knows the cookie name + how to verify the signature). The hook just hands you the cookies.

## Reading query params

For SSR pages that need to parse the URL's query string:

```tsx
import { useSearchParams } from '@voltro/web'

export const renderMode = 'ssr' as const

export default function SearchPage() {
  // Native `URLSearchParams`, resolved server-side from the SSR request
  // URL (correct in the first paint) and from `window.location.search`
  // on the client — same call site on both sides.
  const q = useSearchParams().get('q') ?? ''
  const results = q ? searchDocs(q) : []
  return /* … */
}
```

`useSearchParams()` returns the request's query as a native `URLSearchParams`. SPA pages
that must react to router-pushed query changes without a reload re-render through the router
(`useNavigate`/`useLocation`); the hook then re-resolves on that render.

Prefer the typed form where the page declares a `searchParams` schema export —
`useSearchParams(searchParams)` returns the decoded shape instead of a raw
`URLSearchParams`. See [Routing hooks](/docs/reference/hooks-routing#usesearchparams--usesetsearchparams).

## Reading cookies

```ts
const req = useServerRequest()
const sessionCookie = req?.cookies['voltro:session']
```

`cookies` is already parsed — no manual `Cookie:` header splitting needed.

## Reading headers

```ts
const req = useServerRequest()
const userAgent = req?.headers['user-agent']
const tenant = req?.headers['x-tenant']
```

Headers are lower-cased keys. Multi-value headers are joined with `, `.

## Conditional rendering by SSR vs client

```tsx
const req = useServerRequest()
if (req) {
  // SSR — req available
} else {
  // Post-hydration — read browser-side equivalents
  const ua = navigator.userAgent
}
```

For values that need to flow from SSR to client (cookie-derived subject, locale), serialise them via `SubjectProvider`-style context. The client doesn't re-read cookies — it inherits what the SSR pass decoded.

## What it's NOT for

- **Reading database state.** Use a `loader` — it runs server-side too, but with a typed `params` + abort signal + a clear server/client divide.
- **Triggering side effects during render.** React's render must be pure. For SSR side effects (logging a request), use middleware in `app.config.ts.runtime`.
- **Client-side cookie reading.** The session cookie is `HttpOnly` — JS can't read it. The server decoded it during SSR + handed you the subject; that's what the client sees.

## Mounting check pattern

```tsx
const req = useServerRequest()
const isSSR = req !== null

return (
  <div>
    {isSSR ? <ServerOnlyBranch /> : <ClientOnlyBranch />}
  </div>
)
```

Use sparingly — divergent SSR / client rendering causes hydration mismatches. The cleaner pattern is `useEffect(() => setHydrated(true), [])` + render the same JSX in both paths.

## See also

- [Loaders & meta](/docs/routing/loaders-and-meta) — server-side data fetch (preferred for data)
- [Authentication / React](/docs/authentication/react) — `SubjectProvider` pattern
- [Wire protocol](/docs/data/wire-protocol) — the server's view of the request



---

<!-- source: en/reference/runtime-context.md -->
## Runtime context

_The `AppContext` passed to server executors._

Every server executor receives an `AppContext` as its second argument:

```ts
import type { AppContext } from '@voltro/runtime'

export default async (input: Input, ctx: AppContext) => {
  const subject = ctx.request.subject
  const rows = await ctx.store.query(/* ... */)
}
```

Query, mutation, action, stream, and workflow files export both descriptor metadata and the default executor. The default executor receives `ctx`; descriptor objects stay data-only and browser-safe.

## `ctx.request`

Per-call runtime metadata:

```ts
ctx.request.subject   // user, apiKey, system, or anonymous subject
ctx.request.traceId   // trace id shared with client/server spans
ctx.request.spanId    // current span id when tracing is active
ctx.request.clientId  // WebSocket connection id for RPC calls
```

Most auth and tenant checks read `ctx.request.subject`:

```ts
const tenantId = ctx.request.subject.tenantId
if (ctx.request.subject.id == null) throw new Error('sign-in required')
```

## `ctx.store`

The typed mutation store. It applies schema mixin behavior for `audit()`, `tenant()`, and `softDelete()` where configured.

```ts
await ctx.store.insert('notes', { title: 'Hello' })
await ctx.store.update('notes', noteId, { title: 'Updated' })
await ctx.store.delete('notes', noteId)

const rows = await ctx.store.query(database.notes.descriptor)
```

`ctx.store.query` returns the row type the builder already knew — `database.notes`
resolves to that table's row, so `rows[0].title` is a `string` with no cast. A
hand-built descriptor still resolves to the untyped `Row`.

Mutations receive a transactional store view. Actions and streams receive a normal store view; writes from them are not automatically rolled back as one unit.

## `ctx.load` / `ctx.loadMany` — request-scoped batching

`relations()` + `.with()` is the right answer whenever the shape of the related
data is known statically: it compiles to ONE query. Reach for it first.

This is for the case it cannot express — assembly whose shape depends on the
DATA. A breadth-first walk over a tree is the canonical example: each level's
ids come from the level above, so no declarative relation spec covers it, and
the natural code is one query per node.

```ts
let level = [await ctx.load('nodes', rootId)]
while (level.length > 0) {
  const childIds = level.flatMap((n) => n?.childIds ?? [])
  if (childIds.length === 0) break
  level = [...await ctx.loadMany('nodes', childIds)]
}
```

Every `load` issued in the same tick for the same table is coalesced into one
`WHERE id IN (...)`, so that walk costs one query per LEVEL, not per node.
Repeated ids — a diamond where two parents share a child — are fetched once.
A missing row is `null` rather than a throw, because a dangling edge in a graph
walk is usually data; use `.one()` when absence is an error.

The cache lives exactly as long as the request. That is a correctness
requirement, not a tuning choice: a longer-lived cache would serve one
subject's rows to another (a data-isolation bug on a tenant-scoped store) and
would go stale across a mutation in the same request.

## `ctx.cache`

Async cache facade for request handlers:

```ts
const value = await ctx.cache.wrap(
  `summary:${id}`,
  { ttlMs: 60_000, tags: ['notes'] },
  () => computeSummary(id),
)

await ctx.cache.invalidateTag('notes')
```

Mutation `target` metadata can invalidate matching tagged cache entries automatically.

## `ctx.kv`

Durable key-value facade — always present, and unlike `ctx.cache` its entries are **never evicted for capacity** (they live until deleted or their TTL lapses). Use it for state you can't recompute; the default backend on a sql app is the database, so values survive restarts.

```ts
const state = await ctx.kv.getOrElse(`onboarding:${userId}`, () => ({ step: 0 }))
await ctx.kv.set(`onboarding:${userId}`, { step: state.step + 1 })
```

`get` / `getOrElse` / `set` (optional `{ ttlMs }`) / `delete` / `has` / `list(prefix)` / `clear`. See **[Durable key-value](/docs/caching/key-value)**.

## `ctx.webhooks`

Present only when the webhooks plugin is configured. Plugin-specific packages expose typed helpers for their optional context slots; the core runtime keeps the slot structurally typed so apps do not pay for unused plugins.

## `ctx.workflows`

Present when the API app has discovered workflows. It starts durable work and controls existing runs:

```ts
const run = await ctx.workflows.start('notes.summarise', { noteId })
await ctx.workflows.signal({ id: run.id }, 'approval', { approved: true })
const approval = await ctx.workflows.update({ id: run.id }, 'approve', { decision: true })
const latest = await ctx.workflows.query('notes.summarise', run.executionId)
const snapshot = await ctx.workflows.wait('notes.summarise', run.executionId)
```

Use `signal(...)` for fire-and-forget external events. Use `update(...)` when the caller needs a tracked result from the workflow's `awaitUpdate(...)` handler.

Inside a mutation, `ctx.workflows.start(...)` queues the actual launch until after the transaction commits. If the mutation rolls back, the workflow is not started. `ctx.workflows.run(...)` and `start(..., { wait: true })` are intentionally rejected inside mutation transactions.

Workflow starts record their start source (`workflow-rpc`, `app-context`, `schedule:<name>`, `incoming:<id>`, or `inspect`). Request-backed starts also carry the current request trace, and authenticated request starts carry the resolved subject into the run context. Keep authorization-critical tenant/user ids in the workflow payload so resumed work stays deterministic.

## Effect Services

Server executors may return `Effect`s. Actions and streams receive the base platform layer, including `HttpClient`. AI helpers are imported from `@voltro/ai`:

```ts
import { generateText } from '@voltro/ai'
import { Effect } from 'effect'

export default (input: { prompt: string }) =>
  Effect.gen(function* () {
    const { text } = yield* generateText({ prompt: input.prompt })
    return { text }
  })
```

### Writing a wrapper that PROVIDES a service

A helper that provides a service must be generic over `R` and **subtract** the tag
it provides, or it silently narrows what callers may pass it. The failure mode is
confusing because it shows up at the CALL site, not in the wrapper:

```ts
import { Context, Effect } from 'effect'

class Tenant extends Context.Tag('Tenant')<Tenant, { readonly id: string }>() {}

// WRONG — `R` defaults to `never`, so this only accepts effects that need
// nothing else. Pass it an effect that also needs `Db` and it stops compiling.
const withTenantBad = <A, E>(id: string, effect: Effect.Effect<A, E>) =>
  Effect.provideService(effect, Tenant, { id })

// RIGHT — generic over `R`, and the return type SUBTRACTS the tag it provided.
const withTenant = <A, E, R>(
  id: string,
  effect: Effect.Effect<A, E, R>,
): Effect.Effect<A, E, Exclude<R, Tenant>> =>
  Effect.provideService(effect, Tenant, { id })
```

`Exclude<R, Tenant>` is what makes the wrapper composable: the caller's remaining
requirements pass through untouched, and only the tag you actually supplied
disappears. Without it, the wrapper's own signature dictates the caller's entire
requirement set.

The same rule applies to any callback the framework takes from you (an AI tool
body, a media generator, a workflow step): declare its `R` as `unknown` rather
than letting it default to `never`, then let the framework's own bridge discharge
it. A callback typed `Effect<A, E, never>` cannot use ANY service, which is
rarely what you meant.

## Context By Primitive

| Server file | Transaction | Typical store usage | External I/O | Returns |
|---|---|---|---|---|
| `*.query.server.ts` | No | Read/query | Avoid | Query descriptor or computed value |
| `*.mutation.server.ts` | Yes | Atomic writes | Avoid | Unary output |
| `*.action.server.ts` | No | Optional, non-atomic | Yes | Unary output |
| `*.stream.server.ts` | No | Optional, non-atomic | Yes | `Stream`, `Effect<Stream>`, or `Promise<Stream>` |
| `*.workflow.tsx` | Step-specific | Durable reads/writes | Yes, inside activities | Workflow result |

Use mutations for atomic state changes, actions for one-shot side effects, streams for progressive element output, and workflows for durable multi-step work.



---

<!-- source: en/reference/startup.md -->
## Startup hooks

_"*.startup.tsx — run code once when the app boots: warm a cache, open a long-lived connection, start a background consumer. Default-export a function; register teardown via onShutdown."_

A **startup hook** runs code once when the app boots — after migrations, once the rpc server is listening. Use it for the long-lived, app-singleton work that doesn't fit any request-driven primitive: warm a cache, open a persistent connection (a message-broker consumer, a websocket to an upstream), start a background interval, or register a process-wide service.

A `*.startup.tsx` (or `*.startup.ts`) file **default-exports a function** — there's no `define*` wrapper:

```tsx
// startup/warmCache.startup.ts
import type { StartupContext } from '@voltro/runtime'

export default async ({ store, log, onShutdown, id }: StartupContext) => {
  log.info('warming the dashboard cache')
  const timer = setInterval(() => void refreshDashboardCache(store), 60_000)

  // Register teardown — runs on SIGTERM / SIGINT, LIFO across all startups.
  onShutdown(() => clearInterval(timer))
}
```

Discovery walks every `*.startup.tsx`; the `default` export must be a function. Multiple startups in one app are fine — each gets its own scope and is torn down independently.

## The context

```ts
interface StartupContext {
  readonly store:      DataStore                                  // already-migrated
  readonly log:        SyncLogger                                 // scope=startup:<id>
  readonly onShutdown: (cb: () => void | Promise<void>) => void   // register teardown
  readonly id:         string                                     // basename without .startup.tsx
}
```

- **`store`** is the same framework `DataStore` handlers use — already migrated by the time the hook runs.
- **`log`** is scoped to the file's basename, so `voltro logs --filter startup:<id>` isolates a hook's output.
- **`onShutdown(cb)`** registers a teardown callback. On `SIGTERM` / `SIGINT` the framework runs every registered callback in **reverse** order (LIFO), awaiting each — with a hard 5s timeout per callback so a hung teardown can't block shutdown. Always release what you acquire here.
- **`id`** is the stable basename (no `.startup.tsx`), handy for keys / log scoping.

## Lifecycle

- **Runs once, at boot** — after the schema is migrated and the rpc server is listening.
- **A failure REFUSES the boot.** A throw or rejection aborts startup, naming the file and the cause. So do a startup file that cannot be imported, and one with no default-exported function. This used to be a warning that let the server come up, and the reason it changed is that a startup is where an app REGISTERS things the request path depends on — `setRowFilter` above all. A server that came up without its row filter looked healthy and was not. If a failure is genuinely acceptable for one startup, catch it inside that function, where a reviewer can see the decision.
- **Long-lived in effect, but the function RETURNS.** Start the work, hand the teardown to `onShutdown`, return. That's the point — startups are for persistent process-wide work, unlike [seeds](/docs/database/seeds) which run once and return. A function that never returns refuses the boot after `VOLTRO_STARTUP_TIMEOUT_MS` (60s default), because awaiting forever is what made a slow startup that then failed impossible to report: the boot had already moved on, so the failure arrived with nothing left to refuse.
- **Torn down cleanly** — every `onShutdown` callback fires on process exit (LIFO), so connections and timers release without leaking across supervisor restarts.

## When to use it vs the alternatives

| Need | Use |
|---|---|
| Seed rows once at boot, then return | [`*.seed.ts`](/docs/database/seeds) |
| Hold a long-lived connection / interval / background consumer for the process lifetime | `*.startup.tsx` (this) |
| React to every commit on a table | [`*.subscribe.ts`](/docs/data/subscribers) |
| Periodic work on a cron schedule | [`*.cron.tsx`](/docs/scheduling/overview) |
| Durable, crash-surviving multi-step work | [`*.workflow.tsx`](/docs/workflows/overview) |

If the work is "do X once and finish", it's a seed. If it's "keep X running until the process stops", it's a startup.



---

<!-- source: en/reference/templates.md -->
## App templates

_The scaffolding catalogue — the api / web / serverless / mobile templates, what each demonstrates, and when to pick it._

Every template is a dogfooded, runnable reference. Scaffold one with
`voltro create-project --api <id> --web <id>` (or `voltro add-app <name>
--template <id>`).

**`voltro list-templates` is the authority, not this page.** It prints the
templates your installed CLI actually ships — there are dozens — while the
tables below cover the ones worth a paragraph of explanation. If an id appears
in the command's output and not here, it exists and works.

Templates come in **four kinds**, matching the four things you deploy:

- **`api`** — a long-running backend (`app.config` `type: 'api'`).
- **`web`** — a frontend (`type: 'web'`); gets a dev-server port.
- **`serverless`** — a bundle of standalone [`*.serverless.ts`
  functions](/docs/deployment/serverless-functions) shipped on their own with
  `voltro serverless`. No long-running server, no port — add it to a project
  with `voltro add-app`.
- **`mobile`** — an Expo (React Native) app (`mobile-app`), scaffolded with
  `--mobile`. Expo owns its own dev loop, so it is not part of `voltro dev`; the
  sibling api still is, and the app consumes it over the network with the same
  typed hooks the web app uses.

## API backends (`kind: api`)

| Template | What you get |
|---|---|
| `api-backend` | The minimal base: `app.config` + a `*.entity.ts` schema + one streaming query + one mutation. Tenant-aware out of the box. Start here. |
| `api-backend-mail` | `api-backend` + [`@voltro/plugin-mail`](/docs/plugins/mail) and a React-Email welcome template you preview/send from the dashboard. |
| `api-backend-storage` | `api-backend` + [`@voltro/plugin-storage`](/docs/plugins/storage): public (CDN-direct) + private (policy + per-object grants) objects, with upload examples. |
| `api-backend-mariadb` | MariaDB-backed: binlog CDC real-time, file storage, tenant-aware schema — the shape for K8s multi-replica apps. |
| [`api-durable`](/docs/templates/api-durable) | The whole durable + reactive surface in one order-fulfillment domain: a workflow (`step`/`sleep`/`awaitSignal`), an event trigger, a cron schedule, a table subscriber, a materialized aggregate, and a startup hook. |
| [`api-ai`](/docs/templates/api-ai) | A RAG support agent: a `vectorEmbedding()` docs table, a search tool, a real `defineAgent`/`defineAgentExecutor` model loop, and a `generateObject` action. |
| [`api-data-advanced`](/docs/templates/api-data-advanced) | The advanced schema DSL: `*.entity.ts`/`*.relations.ts` split + eager `.with()`, full-text search, `dbEnum`, array/generated/encrypted columns, and declarative query caching. |
| [`api-auth`](/docs/templates/api-auth) | Real user auth — `@voltro/plugin-auth` turnkey: password sign-up/in/out over HttpOnly session cookies + a strategy resolving the session to a typed Subject. Zero-infra boot. |
| [`api-rest`](/docs/templates/api-rest) | Public REST API — `defineRestRoute` (query/path/body, scope guards, Idempotency-Key) + `@voltro/plugin-openapi` (OpenAPI 3.1 spec + Swagger UI at `/docs`). Zero-infra boot. |
| [`api-saas`](/docs/templates/api-saas) | The SaaS plugin bundle — billing entitlements + notifications + analytics + presence, wired turnkey; one `projects.create` exercises three together. Zero-infra boot. |
| [`api-observability`](/docs/templates/api-observability) | Production-readiness — Prometheus `/metrics` + a custom counter, Sentry (inert without a DSN), tracing, and a `@voltro/testing` unit test (`voltro test`). Zero-infra boot. |
| [`api-webhooks`](/docs/templates/api-webhooks) | First-class webhooks both ways — a signature-verified incoming `*.webhook.tsx` receiver + an outgoing `defineEvent` emitted via a durable signed delivery workflow. Zero-infra boot. |

## Web frontends (`kind: web`)

| Template | Render mode | What it demonstrates |
|---|---|---|
| `frontend-blank` | — | Empty React + layout shell + one page. The blank canvas. |
| [`frontend-app`](/docs/templates/app) | `full` · reactive | **The reactive end-to-end loop** — a web frontend wired to an api (`useSubscription` + `useMutation` + auto-optimistic). The only fullstack template; pairs with `api-backend`. |
| `frontend-landing` | `static` · `interactive: 'none'` | Marketing page that ships **zero JS** on the wire. |
| `frontend-static-blog` | `static` + `islands` | **SSG from a content source**: dynamic `[slug]` routes pre-rendered via `getStaticPaths`, per-post `meta` from loader data, and ONE island (reading-progress bar) showing selective hydration. |
| `frontend-spa` | `spa` | A **pure client-rendered** tool — state lives in the browser (`localStorage`), so there's nothing to SSR. Shows where a single-page app is the right call. |
| `frontend-ssr` | `ssr` + `isr` | **Server-rendered pages**: a per-request SSR page (`useServerRequest()` for cookies/headers) + ISR pages (`revalidate`, `staleWhileRevalidate`, tenant-aware caching). Needs a runtime (`voltro start`), not a CDN. Self-contained; swap the loader for `ctx.query` to read from your api. |
| `frontend-contact` | `static` + serverless | **Static page + a serverless email form** (see combos below). |
| `frontend-docs` | `static` (catch-all SSG) | Docs site: `docs/[...slug].tsx`, per-section layout, URL-prefix i18n. |
| `changelog` | `static` | Release-notes site: MDX in `content/releases/`, rendered list + per-release pages, RSS feed. |

## Serverless functions (`kind: serverless`)

| Template | What you get |
|---|---|
| `edge-functions` | A **library of `*.serverless.ts` functions** covering the range of what edge functions do — pure compute, request-header/geo, outbound HTTP (fire-and-forget + fetch-and-transform), Web Crypto HMAC verify, an LLM call, and status-controlled errors. Run with `voltro serverless dev`, ship to node / Cloudflare / Scaleway. |

## Static + serverless combos

The most common shape for a no-always-on-server product: **a static page on a
CDN + a serverless function for the one dynamic bit.** Two templates show it:

- **`frontend-contact`** — a static marketing page whose contact form (an
  island) POSTs to `functions/sendMessage.serverless.ts`, which sends mail via
  Resend. The page ships to a CDN; the function scales to zero. The function's
  local dev server sends CORS headers (same as the edge hosts), so the
  cross-origin form works out of the box.
- **`edge-functions` + any static web template** — add the function library to
  a project that also has a `frontend-static-blog` or `frontend-landing`, and
  wire the page to whichever function it needs.

```bash
# Scaffold the combo:
voltro create-project acme --web frontend-contact --no-input
#   → apps/acme/contact/  (static page + functions/sendMessage.serverless.ts)

# Run both halves locally (two terminals):
pnpm --filter @acme/contact dev          # the static site
pnpm --filter @acme/contact fn:dev       # the serverless function on :8910
```

## Picking a template

| You want… | Start with |
|---|---|
| A reactive backend (queries/mutations/workflows) | `api-backend` (+ `-mail` / `-storage` for those features) |
| A marketing / brochure site | `frontend-landing` |
| A blog or content site generated at build time | `frontend-static-blog` |
| A heavily-interactive tool with no backend | `frontend-spa` |
| Server-rendered pages (per-request SSR, or cached + revalidated ISR) | `frontend-ssr` |
| A static site that needs ONE dynamic endpoint (email, webhook, …) | `frontend-contact` |
| Docs | `frontend-docs` |
| Standalone functions to deploy to the edge | `edge-functions` |

## See also

- [Scaffolding](/docs/cli/scaffolding) — the `create-project` / `add-app` / `list-templates` commands + writing your own template
- [Serverless functions](/docs/deployment/serverless-functions) — the `*.serverless.ts` model + deploy targets
- [Static-site deploy](/docs/deployment/static-sites) — shipping a web template's `dist/` to a CDN
