Reactions
Standing reactive agents — a *.reaction.tsx watches a table and runs an agent/workflow on a change behind mandatory spend guards (dedupe / rate-limit / budget).
Reactions (*.reaction.tsx)
A reaction watches a table and, on a relevant change, runs an agent or
workflow — behind MANDATORY spend guards. It's the data-driven sibling of a
subscriber: where a subscriber is "run my code on a
change," a reaction is "run an agent/workflow on a change, without footgunning a
spend storm." Discovery + dispatch are automatic — drop a *.reaction.tsx file
in and voltro dev binds it to the post-commit change stream.
// reactions/flagBigOrder.reaction.tsx
import { defineReaction } from '@voltro/runtime'
export default defineReaction({
name: 'flagBigOrder',
watch: { table: 'orders', on: 'insert' }, // table (+ optional op filter)
when: (e) => Number((e.new as { total?: number })?.total ?? 0) > 1000, // optional predicate
act: { kind: 'workflow', workflow: 'orders.review' }, // run THIS workflow on the change
guards: {
// REQUIRED — a stable idempotency key so the same change acts ONCE. Without
// it, a reaction whose act writes the watched table self-triggers into a
// spend storm; defineReaction throws at boot if it's missing.
dedupeKey: (e) => String((e.new as { id?: string })?.id ?? ''),
rateLimit: { limit: 10, windowMs: 60_000 }, // optional per-reaction cap
costBudgetUsd: 5, // optional per-tenant AI ceiling
},
})How it fires
Both voltro dev and voltro serve discover every *.reaction.tsx, bind it to
the store's post-commit onChange channel, and on each change run the act
through the guard chain: op/table filter → when predicate → dedupe →
rate-limit → budget. The changed ROW is handed to the act — as the workflow's
payload (so its payload schema should match the watched table's row), or seeded
into the agent's prompt.
Guards (the point)
dedupeKey(required) — the same logical change acts exactly once. This is what stops a reaction whose act writes the watched table from self-triggering forever.defineReactionthrows at boot if it's missing.rateLimit(optional) — at mostlimitfirings perwindowMs.costBudgetUsd(optional) — a per-tenant AI spend ceiling; over budget, the reaction refuses (fails closed).
The two act targets
act: { kind: 'workflow', workflow }— starts the workflow on the change (durable, retryable; the row is the payload). Reach for this when you need durable multi-step orchestration.act: { kind: 'agent', agent }— fires the agent headlessly: opens a fresh thread, seeds it with the change as the prompt, and drives the synthesized<agent>.sendas the agent actor (tenant-scoped to the row). The assistant's turn streams onto the durable<agent>.messagesthread. Reach for this when a standing agent should react in natural language.
Limits (v1)
- Best-effort + fire-and-forget (like subscribers) — a failing act logs + continues; it can't back-pressure the change stream. Durability comes from a workflow act (an agent act is best-effort).
dedupeKeyis in-memory per process in v1 (it stops the self-trigger storm within a run); a durable cross-restart dedupe table is a follow-up.
When to use what
- Run my own code on a change →
*.subscribe.ts. - Run an agent/workflow on a change, with spend guards →
*.reaction.tsx(this). - Reject a write / derive a value at write time → schema DSL
(
validate(Schema)/computed/default).