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:

// 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.tsapis. It is now spelled exactly once per app instead of once per call site:

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.

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:

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 !:

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:

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 is its own state — loading: false, failed: true, error non-optional — so branching on loading alone is safe; render the failure off failed.

Use { skip } to defer until inputs are ready:

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

Offline semantics with the local-first mirror

With @voltro/local-first's query mirror bound (see the sync engine), useSubscription's behaviour extends offline WITHOUT a second API: a cold start seeds data (and revision) from the device's mirrored rows for the subject's partition, so loading resolves against local data when the server is unreachable; the first live event replaces it, and a reconnect inside the resume window continues with deltas from the mirrored revision. Offline WRITES ride useOutbox — durable with outboxPersistence(), conflict resolution via resolveConflict.

useMutation(apiName, rpcTag)

Calls a defineMutation RPC.

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

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.

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 }:

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.

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.

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:

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:

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:

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.

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:

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:

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:

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, not one run's internal timeline.

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.

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.

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.

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