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. defineReaction throws at boot if it's missing.
  • 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 is 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).