Concept · 3 of 7

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 event

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

  1. Read. Pull new events on the source scroll since the last cursor.
  2. Filter. Keep only events matching the topic (if set).
  3. Handle. Pass matches to the handler as a batch.
  4. 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.

Source / state scroll binding
implemented
Reactors take two scrolls at construction — source to read, state to park the cursor. Outputs go wherever the handler writes them.
Single-topic filter (OnTopic)
implemented
OnTopic(t) narrows delivery to a single topic. Omit it to receive every event on the source scroll.
Cursor persisted as an event
implemented
The cursor itself is a reactor.cursor event on the state scroll. Advance only happens after the handler returns nil — failures replay from the same position.
Manual Process() tick
implemented
node.Process(ctx) runs exactly one pass: read new events from source, call the handler, advance the cursor. Safe to loop yourself in tests or scripts.
Dispatcher (poll + notify + claim)
implemented
NewDispatcher wraps the tick in an interval loop with optional push notification and claim-based coordination, so multiple processes can share reactors without double-processing.
Multi-topic OnTopic(a, b, c)
designed
A common pattern for reactors that span several related topics (e.g. an intent detector watching three kinds of intent). Today each reactor binds one topic; a multi-topic filter lands with the next reactor revision.
Typed event payloads
sdk-shimmed
Handlers today receive scroll.Event with a []byte Data field — unmarshal manually. The DSL codegen emits per-topic types; wiring those directly into the handler signature is next.
Per-event retry / dead-letter
designed
A handler failure today aborts the tick — the cursor doesn't advance and the full batch retries next tick. Fine-grained per-event retry, backoff, and dead-lettering are on the roadmap.