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.
What you get out of the box#
| Package | Use it for | SDK |
|---|---|---|
@swirls/looms/agent | Conversational or tool-using LLM agents | defineAgent, defineTool, spawn other agents or workflows as tools |
@swirls/looms/workflow | DAGs: nodes, deps, sleeps, nested runs | defineWorkflow; a node can return a value, spawn a child, sleep, or emit effects |
@swirls/looms/approval | Human gates from agents or workflows | gate({ title }) parks the run until approval.decided |
@swirls/looms/evaluator | Typed questions that branch a run | defineEvaluator, 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:
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:
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.
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.projectionsWhat the scope gives you#
Catalog through React typing: Type safety.
- Typed events:
event.typeandevent.payloadare narrowed inside threadstepand projectionreducebased on the module's catalog. - Typed returns and emit:
executereturn values andctx.emitare 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:
defineModulerequires 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:
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 passctx.effectIdas 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.
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.
execute: async (input, ctx) => { const res = await fetch(...) return [{ ... }]}3. Effect-TS & Services
For typed dependencies, structured concurrency, and compile-time service checking.
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:
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:
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:
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:
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:
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-exportsSmall 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;
agentandworkfloware 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: Passinputorshape(a Standard Schema or Effect Schema). The factory infers handler and state types — do not pass generics.