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.

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

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

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.emit(...) — 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