Reactor
A reactor is the active half of the scroll model. Scrolls hold events; reactors turn new events into more events. Every subscription pattern in weave — tool dispatch, prompt hydration, projection, enrichment, reconciliation — is a reactor at bottom.
Active subscription, as data
A scroll is a passive store. Nothing happens until something reads it. A reactor is the thing that reads it — and records its own position as part of the data. Give it a source scroll to watch, a state scroll to park its cursor, and a handler to call when new events match. That is the whole primitive.
The shape is deliberately small. Every higher-level pattern
in weave is expressed as a reactor or a composition of
reactors: a tool handler is a reactor on tool.dispatch, a projection is a reactor on a
domain scroll, a reconciler is a reactor on a proposal
topic. Learn the reactor and the rest of the system stops
needing new vocabulary.
Anatomy
Four moving parts — source, state, an optional topic filter, and a handler. Register with the weave engine and the node becomes eligible for ticking.
// A reactor watches a source scroll and advances a cursor on a state scroll.
postQuest := weave.NewReactor(
"post-quest",
questBoardScroll, // source: read-only
posterState, // state: cursor lives here
weave.OnTopic(TopicQuestAccepted), // optional topic filter
weave.HandleFunc(func(ctx context.Context, events []scroll.Event) error {
for _, e := range events {
// work: fold existing state, emit to other scrolls, call tools, …
_ = e
}
return nil
}),
)
// Register alongside any agents; reactors become part of the Weave graph.
if err := w.Register(postQuest); err != nil {
return err
}The source is read-only from the reactor's point of view. The state scroll is where the cursor lives — and, by convention, where you park any private bookkeeping the reactor needs across ticks. Outputs are arbitrary: a reactor typically writes new events to other scrolls entirely.
The cursor is an event
A reactor's position on its source scroll is not a hidden
variable. It's a reactor.cursor event appended
to the state scroll on every successful tick. This keeps the
rule consistent: every fact in weave lives on a
scroll, including "where did this reactor get to."
state: reactor-post-quest
────────────────────────────────────
[0] reactor.cursor { sequence: 47 } // persisted after batch 0..47 succeeded
[1] reactor.cursor { sequence: 51 } // next tick advanced to 51
[2] reactor.cursor { sequence: 73 } // and again — each advance is itself an eventWhy it matters: crash mid-tick and you replay from the last committed cursor — never further. The state scroll itself is recoverable, inspectable, and as durable as any other scroll. No separate cursor table, no out-of-band offset store.
The tick cycle
A single Process call does four things, in
order:
- Read. Pull new events on the source scroll since the last cursor.
- Filter. Keep only events matching the topic (if set).
- Handle. Pass matches to the handler as a batch.
- Advance. Append the new cursor to state — but only if the handler returned nil.
The contract that falls out: handlers must be idempotent. If the handler succeeds partially and then fails, the cursor doesn't advance, so the next tick replays the whole batch. That is the price of single-owner progress being a scroll fact. Writing outputs as events (rather than mutating external state directly) makes replay a no-op — the same append on a scroll that already has it is deduped downstream by topic + payload identity.
Five canonical roles
Reactors are the primitive; these are the shapes that keep
showing up in real pipelines. They are conventions, not
separate types — each is just a NewReactor wired to specific topics.
Reconciler
Proposals in, verdicts out.Subscribes to proposal events on a signals scroll, folds existing accepted state, and emits signal_accepted or validator_rejected. The gatekeeper between intent and materialization.
Projector
Verdicts in, domain entities out.Subscribes to signal_accepted, looks up the original proposal, and creates the domain artifact (a quest, a monster, a guild roster entry). Writes back a projection marker so downstream reactors can map signal IDs to entity IDs.
Enricher
Domain events in, parallel LLM calls out.Subscribes to projection markers or domain events, fans out N nano agents in parallel, and writes one atomic event per result. Each axis of enrichment is independently retriable.
Conflict detector
Intents in, conflict/resolved verdicts out.Subscribes to intent_edit, intent_remove, intent_rename. Folds the signal-status map from the source scroll, checks the target exists and isn't rejected, emits conflict_detected or conflict_resolved.
Intent applier
Resolved intents in, domain mutations out.Subscribes to conflict_resolved, translates the intent into the right domain event on the right domain scroll. The last step of the three-layer flow — signals turn into state.
// A reconciler — one of the five canonical reactor roles.
// Reads an in-memory fold of the source scroll, decides a verdict, emits it.
reconcile := weave.NewReactor(
"reconcile-quest",
questBoardScroll, heraldState,
weave.OnTopic(TopicQuestProposed),
weave.HandleFunc(func(ctx context.Context, events []scroll.Event) error {
// Fold the source directly — always consistent with the latest append.
accepted, err := scroll.FoldEvents(ctx, questBoardScroll, AcceptedQuestsFold)
if err != nil {
return fmt.Errorf("fold accepted: %w", err)
}
for _, e := range events {
verdict := decide(ctx, e, accepted) // LLM call, lookup, whatever
switch verdict.Kind {
case VerdictAccept:
consumer.AppendQuestAccepted(ctx, ...)
case VerdictDuplicate, VerdictReject:
consumer.AppendHeraldRejected(ctx, ...)
}
}
return nil
}),
)The example is a reconciler. Same skeleton as any reactor —
it's the pairing of source, topic, and fold that gives it
its role. Swap the fold and the verdict logic and you have a
conflict detector; swap them for a w.Execute fan-out
and you have an enricher.
Ticking — manual or dispatched
A reactor does nothing by itself. Someone has to call Process. In tests and simple scripts that
someone is you. In production it's the dispatcher — a loop
that polls at an interval, optionally wakes up on a
notifier, and coordinates via claims so two processes don't
work the same batch.
// Two ways to tick a reactor.
// 1) Manual — one call runs one pass.
if _, err := postQuest.Process(ctx); err != nil { return err }
// 2) Dispatched — a loop with polling, optional push notifier, and claims
// so only one consumer processes a given batch.
d := weave.NewDispatcher(w,
weave.WithInterval(2*time.Second), // poll cadence
weave.WithNotifier(questBoardScroll), // push wake-up on new events
weave.WithClaimTTL(30*time.Second), // coordinated delivery
)
if err := d.Start(ctx); err != nil { return err }
defer d.Stop()The dispatcher is optional. Code that calls Process in-line still works — that is the test
rig path. Reach for the dispatcher when you want the
reactor to keep running without ambient glue.
In-memory folds, not projection reads
A reactor often needs to know "what have we accepted so far?" or "which signals did the previous reconciler resolve?" The instinct is to query a MongoDB projection. Resist it.
Inside a handler, fold the source scroll directly. Folds replay from the current event stream and are always consistent with the latest append — no catch-up barrier, no stale read. A projection is a consumer of reactor output; using it as an input inverts the dataflow and reintroduces the race the reactor model was supposed to eliminate.
Not to be confused with
- A goroutine subscribing to a channel. A goroutine's position in a stream lives in the Go runtime. A reactor's position lives on a scroll — crash, restart, new process, and the cursor is still there.
- A cron job. Cron fires on wall-clock time. A reactor fires on new source events. The dispatcher may poll on an interval, but that's plumbing — the unit of work is "one batch of source events," not "one minute."
- A workflow step. A workflow is a state machine whose transitions are themselves events. A reactor is simpler: one subscription, one handler, one cursor. You compose reactors into pipelines; workflows compose higher than that.
Status
Reactors are shipped and in production use. The single-reactor shape — source, state, topic, handler, cursor-as-event — is stable. The sugar on top (multi-topic, typed payloads, per-event retry) is where the next work lands.