Skip to content
Looms
Documentation
On this page

Modules

Install @swirls/looms, then import only the module subpaths your application needs. Built-in modules cover agents, workflows, approvals, and evaluations; add your own for domain work without forking the runtime. The kernel does not special-case LLM turns or DAG nodes; those are thread kinds contributed by modules, alongside namespaced events, effects, and projections. Vocabulary: Concepts.

Runtime modulesFour distinct capability tiles align over a common runtime base. A plus sign marks a custom module. Agents, workflows, approvals, and your own domain modules contribute capabilities to the same runtime.
Compose the capabilities you need.Agents, workflows, approvals, and your own domain modules contribute capabilities to the same runtime.

What you get out of the box#

PackageUse it forSDK
@swirls/looms/agentConversational or tool-using LLM agentsdefineAgent, defineTool, spawn other agents or workflows as tools
@swirls/looms/workflowDAGs: nodes, deps, sleeps, nested runsdefineWorkflow; a node can return a value, spawn a child, sleep, or emit effects
@swirls/looms/approvalHuman gates from agents or workflowsgate({ title }) parks the run until approval.decided
@swirls/looms/evaluatorTyped questions that branch a rundefineEvaluator, evaluate(), spawn as a child

Compose only what you need. A payments service might ship workflow + approval + a custom charges module, and skip agents entirely.

Configuring modules on a host#

Whether you run inside an in-process host with createLooms, local actors with createLocalActorHost, or edge cells with LoomsDurableObject, modules use the same registration pattern. This composition excerpt assumes the imported application definitions, payments module, and model adapter already exist:

ts
import { createLooms } from '@swirls/looms/runtime'// built-in modulesimport { agent } from '@swirls/looms/agent'import { approval } from '@swirls/looms/approval'import { workflow } from '@swirls/looms/workflow'// custom moduleimport { payments } from './modules/payments'import { assistant, checkout } from './definitions'import { llm } from './llm'export const looms = createLooms({  modules: [    agent({ definitions: [assistant], llm }),    workflow({ definitions: [checkout] }),    approval(),    payments,  ],})await looms.start(assistant, 'Charge $40 after approval')await looms.start(checkout, { amount: 150, currency: 'USD' })

Pass every module you need explicitly — agent(), workflow(), approval(), and any domain modules. Configure modules in that list (e.g. providing an LLM adapter to agent({ llm })). Omitting modules leaves the runtime with no built-in kinds.

start does not care which module owns the definition. An agent and a workflow are two kinds of thread; a module you write adds a third, and looms.start(myDefinition, input) works the same way.

Definitions belong to modules#

Pass named agents to agent({ definitions: [assistant], llm }) and named workflows to workflow({ definitions: [checkout] }). The host gathers their definitions automatically. Include child definitions in their owning modules too. Effects-only modules such as payments or approval need no definitions.

Initialization rejects duplicate kind/name registrations and definitions whose kind the owning module does not implement. Starting or spawning an unregistered definition fails explicitly. The callback form returns a finished module: only returned members are installed, and they are exposed through its effects, projections, and threads properties.

Talk to a running run#

Every input to a run is an event. Modules export small builders so you never hand-write payloads:

ts
import { userMessage } from '@swirls/looms/agent'import { decision } from '@swirls/looms/approval'await looms.signal(runId, [userMessage('Also greet Maya')])await looms.signal(runId, [decision(approvalId, 'approve')])

Your module can do the same: export a function that returns an EventInput, and callers use it with signal or store.commit.

Add your own module#

A module declares namespaced events, effects the host should run, and optional projections for the UI. Compose them with defineModule so event types, payloads, and effects are inferred and type-checked. This complete module simulates charges without contacting a payment provider. Real handlers should be safe to retry; use ctx.effectId as an idempotency key.

ts
import { z } from 'zod'import { defineModule } from '@swirls/looms/core'const Charge = z.object({  chargeId: z.string(),  amount: z.number(),})export 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 },          },        ],      }),    },    projections: {      ledger: m.projection({        name: 'ledger',        shape: z.object({          entries: z.array(Charge),        }),        initialState: { entries: [] },        reduce(state, event) {          if (event.type !== 'payments.charge.authorized') {            return state          }          return { entries: [...state.entries, event.payload] }        },      }),    },  }),)export const { charge } = payments.effectsexport const { ledger } = payments.projections

What the scope gives you#

Catalog through React typing: Type safety.

  • Typed events: event.type and event.payload are narrowed inside thread step and projection reduce based on the module's catalog.
  • Typed returns and emit: execute return values and ctx.emit are checked against the module's catalog events.
  • Typed invoke: invoke(effectDef, input) statically type-checks inputs against the effect's input schema.
  • Compile-time service checks: defineModule requires satisfying the environment requirements (R) of all constituent effects via Effect Layers.
  • Runtime payload validation: Events entering the runtime (via signals or effect outcomes) are validated against catalog schemas at runtime.

Prefer a Zod (or other Standard Schema) object for each event so the payload is validated. When you only need a type, payload<{ chargeId: string }>() from @swirls/looms/core declares it without a runtime schema.

Workflows invoke payments.charge and wait on payments.charge.authorized. Agents can expose the same charge as a tool. See Examples for runnable definitions and Projections for UI integration.

Defining Effects#

Reducers stay pure. Side effects (APIs, charges, LLMs, emails) are requested as effects; the host runs them outside the state machine, retries on transient failure, and appends outcome events to the log. Concepts: Events & effects.

Anatomy of an Effect Handler#

Define effects within a module using scope.effect (or standalone with defineEffect from @swirls/looms/core). Using scope.effect constrains the effect's type name to your module's namespace and ensures that both the returned events and any events passed to ctx.emit match your module's event catalog. This handler excerpt assumes a configured server-side Stripe client and a payments catalog containing the authorized event:

ts
import { z } from 'zod'import { createModuleScope } from '@swirls/looms/core'import { paymentsCatalog } from './events'import { stripe } from './stripe'const paymentsScope = createModuleScope({  namespace: 'payments',  protocolVersion: '1.0.0',  events: paymentsCatalog,})const ChargeCardInput = z.object({  customerId: z.string(),  amount: z.number().positive(),  currency: z.string().default('USD'),})export const chargeCardEffect = paymentsScope.effect({  type: 'payments.chargeCard',  input: ChargeCardInput,  retry: {    maxAttempts: 3,    backoffMs: 250,    maxBackoffMs: 2000,  },  execute: async (input, ctx) => {    // 1. input is pre-validated by ChargeCardInput    // 2. ctx.effectId is unique and stable across retries — pass as idempotency key    const charge = await stripe.charges.create(      {        customer: input.customerId,        amount: Math.round(input.amount * 100),        currency: input.currency.toLowerCase(),      },      { idempotencyKey: ctx.effectId },    )    // 3. Return event inputs to commit to the log (payloads are validated against catalog)    return [      {        type: 'payments.charge.authorized',        payload: {          chargeId: charge.id,          amount: input.amount,          currency: input.currency,        },      },    ]  },})

Input Validation#

Pass a Standard Schema V1 validator (Zod, Effect Schema, Valibot, or ArkType) to input. The runtime automatically parses and validates incoming arguments before invoking the handler. If an invalid input is passed, execution fails immediately with an InvalidInputError before calling any external services. The schema also establishes the TypeScript input type for both the handler and caller-side invoke(chargeCardEffect, input).

The Effect Context & Idempotency#

Every handler receives an EffectContext providing metadata and runtime tools:

  • ctx.effectId: A unique, deterministic identifier for this effect invocation. Always pass ctx.effectId as an idempotency key when communicating with external APIs (Stripe, GitHub, payment gateways) or writing to databases. If an actor cell crashes or fails over mid-execution, the replacement actor may re-run the effect. Deduplication depends on the external service honoring the key, its retention window, or your own durable deduplication mechanism.
  • ctx.runId & ctx.threadId: Identifiers of the enclosing run and calling thread.
  • ctx.causingEventId: The sequence ID of the event that caused this effect to be requested, preserving the causal provenance graph.
  • ctx.emit(event): Stream intermediate events to the log while the handler is still executing.

Three Execution Styles: Sync, Async, and Effect-TS#

Looms effect handlers can return three shapes depending on your application's needs:

1. Synchronous

For pure calculations, token counting, or instantaneous transformations.

ts
execute: (input, ctx) => [  {    type: 'payments.computed',    payload: { tax: input.amount * 0.08 },  },]

2. Async / Promise

For standard async/await I/O, fetch requests, or third-party SDK calls.

ts
execute: async (input, ctx) => {  const res = await fetch(...)  return [{ ... }]}

3. Effect-TS & Services

For typed dependencies, structured concurrency, and compile-time service checking.

ts
execute: (input, ctx) =>  Effect.gen(function* () {    const db = yield* DatabaseTag    return [{ ... }]  })

Managing Dependencies with Effect Layers#

When an effect uses Effect.gen and yields dependencies (e.g. yield* DatabaseTag or yield* LlmTag), TypeScript tracks the required service environment in the effect's type signature.

When you call defineModule, Looms inspects the service requirements of all registered effects. If any required service is missing, the build will fail to compile until you supply a matching Layer:

ts
import { Context, Effect, Layer } from 'effect'import { defineModule } from '@swirls/looms/core'import { z } from 'zod'export interface DatabaseService {  insertCharge(chargeId: string, amount: number): Promise<void>}export class DatabaseTag extends Context.Service<DatabaseTag, DatabaseService>()('app/Database') {}export function payments(db: DatabaseService) {  return defineModule({ namespace: 'payments', protocolVersion: '1.0.0' }, (m) => ({    effects: {      recordInDb: m.effect({        type: 'payments.recordInDb',        input: z.object({ amount: z.number() }),        execute: (input, ctx) => Effect.gen(function* () {          const database = yield* DatabaseTag          yield* Effect.tryPromise(() => database.insertCharge(ctx.effectId, input.amount))          return []        }),      }),    },    services: () => Layer.succeed(DatabaseTag, db),  }))}

Live Event Streaming with ctx.emit#

Some effects take time to finish and produce a stream of partial results — for example, streaming LLM tokens back to the user or reporting step-by-step progress during a batch migration. Use ctx.emit(event) to append events to the live log before the effect completes:

ts
const downloadAndProcessEffect = m.effect({  type: 'payments.downloadBatch',  input: z.object({ batchUrl: z.string() }),  execute: async (input, ctx) => {    // Stream progress events as chunks arrive:    for await (const chunk of fetchChunks(input.batchUrl)) {      await ctx.emit({        type: 'payments.progress',        payload: { bytesRead: chunk.length },      })    }    return [{ type: 'payments.batchCompleted', payload: { finished: true } }]  },})

With m.effect, events passed to ctx.emit are checked at compile time against your module's catalog and validated against their schemas at runtime.

Retry Policies & Failure Handling#

External networks and third-party APIs fail. Pass a retry policy to automatically retry transient failures using exponential backoff:

ts
export const callGatewayEffect = paymentsScope.effect({  type: 'payments.callGateway',  retry: {    maxAttempts: 4,     // Four total attempts, including the first    backoffMs: 200,     // Initial backoff    maxBackoffMs: 3000, // Maximum cap  },  execute: async (input, ctx) => {    // If this throws or returns a failed Effect, Looms makes up to four total attempts.    return await sendWithNetworkTimeout(input)  },})

If all retry attempts fail, the host converts the error into a durable runtime.effect.failed event on the log containing the effectId and error message. Threads and workflows can match on runtime.effect.failed to transition state, trigger compensations, or escalate to human review.

Type-Safe Invocations with invoke#

To invoke an effect from a thread reducer or a workflow node, pass the effect definition directly to invoke:

ts
import { invoke } from '@swirls/looms/core'import { chargeCardEffect } from './effects'// Inside a thread's effects() method:effects(state, ctx) {  if (state.status === 'ready_to_charge') {    return [      invoke(        chargeCardEffect,        {          customerId: state.customerId,          amount: state.totalAmount, // Statically typed! Missing or mistyped keys fail compilation        },        `charge_${state.orderId}`,      ),    ]  }  return []}

File layout#

Built-in modules assemble separately authored members with defineModule. Open the folder and the names tell you where to look:

text
src/  events.ts        # namespaced event catalog  scope.ts         # createModuleScope for shared typed builders  threads.ts       # thread kinds (omit if the module has none)  effects.ts       # host-side effect handlers  projections.ts   # read models  signals.ts       # builders for looms.signal / store.commit  module.ts        # defineModule(options, setup) assembly  index.ts         # public re-exports

Small modules can stay in one callback. For larger modules, export a sharedcreateModuleScope(options) from scope.ts, author members across files, and return them from defineModule(options, setup). Only the finished module is passed to the host.

Module Composition Principles#

Modules from different teams interoperate when they follow these rules:

  • Stable namespaces: Every module defines a unique namespace (e.g. agent, workflow, payments). Event and effect type names are scoped to prevent collision.
  • Open-world event catalogs: Applications do not maintain a central event union. Installing modules composes their typed event catalogs automatically.
  • Universal thread kinds: The runtime kernel knows nothing about agents or DAGs. Kinds are open-ended; agent and workflow are kinds contributed by modules.
  • Cross-module projections: While behavioral reducers own their thread's state, projection reducers may observe events across multiple modules (for example, a financial ledger observing both payment events and LLM token usage).
  • Idempotent effect execution: Effect handlers receive ctx.effectId. Handlers must use this ID as an idempotency key when interacting with external APIs (like Stripe or GitHub) where the provider supports idempotency; otherwise implement reconciliation for ambiguous outcomes.
  • Inferred define* types: Pass input or shape (a Standard Schema or Effect Schema). The factory infers handler and state types — do not pass generics.