Skip to content
Looms
Documentation
On this page

Events & effects

Looms separates what has occurred from what the runtime should cause. Replay and crash recovery depend on that split.

Events & effectsAn event stack feeds a reducer plate. Dashed threads reach a world marker and return as a new checked event on the log. A pure reducer reads immutable events, requests effects against the world, and only appends new facts when those effects return.
Facts go in. Intent goes out.A pure reducer reads immutable events, requests effects against the world, and only appends new facts when those effects return.
Event: immutable fact

Something already happened#

Past-tense records written to the log.

  • agent.message
  • workflow.node.finished
  • approval.decided
  • payments.charge.authorized
Effect: requested consequence

Something the runtime should cause#

Imperative intents returned by pure reducers.

  • invoke(charge, { amount: 40 })
  • spawn({ kind: 'agent', definitionName: 'researcher', input })
  • emit({ type: 'payments.charge.requested', payload })
  • wait({ waitId, on: { type: 'approval.decided' } })
Event→Reducer→State + Effects
Effect→World (IO)→Event

During replay or debugging, reducers re-run over historical events to reconstruct state, but the runtime skips effect dispatch. Historical replay does not call external services. Resuming interrupted work can retry an effect whose outcome was not recorded; use provider-supported idempotency keys or reconciliation to prevent duplicate actions. See the reliability guide.

Event catalogs#

Domain events live in a namespaced catalog. Keys become typed event strings (payments.charge.authorized). Builders fix the payload type at the call site:

ts
import { z } from 'zod'import { defineEventCatalog } from '@swirls/looms/core'const Charge = z.object({  chargeId: z.string(),  amount: z.number(),})export const paymentsCatalog = defineEventCatalog('payments', {  'charge.requested': Charge,  'charge.authorized': Charge,})// Typed signal / commit input:paymentsCatalog.input('charge.authorized', {  chargeId: 'ch_123',  amount: 40,})

How those types reach projections and React: Type safety.

Signals#

A signal is an externally originated event injected into the stream: user messages, human approvals, webhooks. Signals steer, wake, pause, or resume parked threads. Internally emitted events and external signals both land on the same log:

ts
await looms.signal(runId, [  paymentsCatalog.input('charge.authorized', {    chargeId: 'ch_123',    amount: 40,  }),])

Defining an effect#

An effect handler is host-side IO. Reducers request it with invoke; the handler returns events that append to the log. Scoped m.effect keeps the type under your module namespace and checks returned event types against the catalog:

ts
import { z } from 'zod'import { defineModule } from '@swirls/looms/core'const Charge = z.object({  chargeId: z.string(),  amount: z.number(),})const payments = defineModule(  {    namespace: 'payments',    protocolVersion: '1.0.0',    events: {      'charge.requested': Charge,      'charge.authorized': Charge,    },  },  (m) => ({    effects: {      charge: m.effect({        type: 'payments.charge',        input: z.object({ amount: z.number() }),        execute: (input, ctx) => [          {            type: 'payments.charge.requested',            payload: { chargeId: ctx.effectId, amount: input.amount },          },          {            type: 'payments.charge.authorized',            payload: { chargeId: ctx.effectId, amount: input.amount },          },        ],      }),    },  }),)const { charge } = payments.effects

Retries, Effect Layers, and ctx.emit live in Modules.

Reducers#

Thread kinds use a pure step (state + event → next state) and an optional effects function that returns runtime instructions. Projection reducers map state + event to next state only. See Projections & projectors for building UIs and indexes.

Effect instruction set#

High-level module effects lower into a small runtime instruction set:

Invoke

External IO (LLM, HTTP, charge a card). Author handlers above or in Modules.

Spawn

Create and start a child thread in the same run.

Emit

Append an event or signal to the run stream.

Wait

Suspend until a matching event, child completion, or timer. Details in Waits & replay.

ts
import { emit, invoke, spawn, wait } from '@swirls/looms/core'// Domain helpers lower into the instruction set:invoke(charge, { amount: 40 })spawn({ kind: 'agent', definitionName: 'researcher', input: { question: 'why?' } })emit({ type: 'approval.requested', payload: { approvalId: 'appr_1', title: 'Refund' } })wait({  waitId: 'appr_1',  on: { type: 'approval.decided', match: { approvalId: 'appr_1' } },})wait({ waitId: 'sleep_1', on: { timerAt: Date.now() + 60_000 } })

Lifecycle events#

The runtime also appends its own lifecycle facts alongside module events:

  • runtime.run.started / completed
  • runtime.thread.started / completed / failed / cancelled
  • runtime.wait.registered / satisfied
  • runtime.timer.set / fired
  • runtime.signal.received

Next: Type safety, Waits & replay, or Modules for full authoring.