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, across the whole fleet. The key is claimed in _voltro_change_claims before the act runs (INSERT-wins on a UNIQUE — the same arbiter the cron scheduler uses), so two replicas seeing one change start one workflow, not two. This is also what stops a reaction whose act writes the watched table from self-triggering forever. defineReaction throws at boot if it's missing.

    The claim is taken BEFORE the act, which is what makes it a gate rather than a report — and the cost is stated rather than hidden: an act that THROWS has already consumed its key and is not re-run by a later duplicate. Durability belongs to the workflow the act starts, not to the trigger.

  • rateLimit (optional) — at most limit firings per windowMs.

  • 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>.send as the agent actor (tenant-scoped to the row). The assistant's turn streams onto the durable <agent>.messages thread. 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).
  • dedupeKey claims survive a restart but not forever: _voltro_change_claims is swept after an hour (VOLTRO_CHANGE_CLAIMS_TTL_HOURS). A change whose key reappears after that window acts again.

Watching a query

The table form above reacts to each CHANGE. It is best-effort: a change made while the reacting process is down, restarting or deploying is not seen. For work that must not be lost, watch the ROWS of a live query instead — the set of open work — and act on every row in it:

// reactions/deployPending.reaction.ts
import { defineReaction } from '@voltro/runtime'

export default defineReaction({
  name: 'deployPending',
  watch: { query: 'deployments.pending' },
  key: (row: { readonly id: string }) => row.id,
  act: { kind: 'workflow', workflow: 'deploy.run', payload: (event) => ({ deploymentId: String((event.new as { id: string }).id) }) },
  resync: '5 minutes',
})

What this form guarantees, and what it asks of you:

  • Nothing is lost. Every delivery of the query is the whole current set, so a row that appeared while this process was away is acted on in the first delivery after it returns.
  • Exactly once across the fleet, with no claim table. Every replica may try to start the workflow for a row; the workflow's idempotencyKey collapses them into one execution. A start that FAILED consumed nothing and is tried again on the next delivery, or on resync when nothing changes. The act must therefore be a workflow, and the workflow must declare an idempotencyKey that identifies the unit of work — a row that can come BACK into the set (an org suspended, unsuspended and suspended again) needs a key carrying that generation: suspend:${orgId}:${suspendedAt}.
  • The workflow moves the row out of the set — its first step flips a status, its failure path records a terminal one. A row still in the set stuckAfter (default 15 minutes) after its workflow was started is reported as stuck and is never restarted: restarting it would hide a loop. resync and stuckAfter take milliseconds or a duration: '30s', '5 minutes', '2 hours', '8 days'.
  • Another app's query, with api: — a service: query of an app this one binds under apis:. The subscription runs over ctx.apis, so the watched app never learns its watcher exists: push, with the dependency pointing one way.
// apps/cloud/worker/reactions/deployPending.reaction.ts
import { defineReaction } from '@voltro/runtime'

export default defineReaction({
  name: 'deployPending',
  watch: { api: 'cloud', query: 'deployments.pending' },
  key: (row: { readonly deploymentId: string }) => row.deploymentId,
  act: { kind: 'workflow', workflow: 'deploy.run' },
})

A local query is read through the same guards, row filter and tenant scope as its REST projection. Each replica holds its own subscription; keep the query to open work — every change delivers the whole set, and more than 1000 rows is a set that has stopped being a queue. rateLimit and costBudgetUsd apply as above; dedupeKey belongs to the table form and is refused here.

voltro_reaction_pending{reaction} is the size of the set, voltro_reaction_oldest_seconds{reaction} its oldest row — the queue lag — and voltro_reaction_connected{reaction} is 0 while the source is not delivering.

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).
  • Process a set of open work — nothing lost, exactly once → a reaction that watches a query, local or another app's.
  • Reject a write / derive a value at write time → schema DSL (validate(Schema) / computed / default).