Runnable examples
These complete files are imported directly from the examples package, where they are typechecked. Copy a file into your project and run it with Bun. Each example here uses memory and deterministic behavior, so it needs no provider key. For persistence across processes, start with the quickstart.
npm install @swirls/looms zod# Save one of the files below, then:bun approval.tsDurable agent session#
The session keeps an ordered mailbox around a pinned agent. Each message has a stable ID, so retrying delivery does not create another turn.
import { setTimeout as sleep } from 'node:timers/promises'import { z } from 'zod'import { agent, defineAgent, defineAgentSession, submitAgentMessage } from '@swirls/looms/agent'import type { EventEnvelope } from '@swirls/looms/core'import { createLooms } from '@swirls/looms/runtime'const responder = defineAgent({ name: 'session-responder', input: z.object({ text: z.string() }), instructions: 'Reply to the current message.', runTurn: ({ input }) => ({ message: { role: 'assistant', content: `Reply: ${input.text}` }, done: true, output: { reply: input.text }, }),})const supportSession = defineAgentSession({ name: 'support-session', agent: responder, idleTimeoutMs: 60_000,})const looms = createLooms({ modules: [agent({ definitions: [responder, supportSession] })],})const { runId, threadId } = await looms.start(supportSession, {})await looms.signal(runId, [ submitAgentMessage('message-1', 'Where is my order?', { threadId }), submitAgentMessage('message-2', 'The order number is 1042.', { delivery: 'followUp', threadId, }),])let closedTurns: EventEnvelope[] = []for (let attempt = 0; attempt < 100 && closedTurns.length < 2; attempt++) { const events = await looms.getEvents(runId) closedTurns = events.filter((event) => event.type === 'agent.session.turn.closed') if (closedTurns.length < 2) { await sleep(10) }}if (closedTurns.length < 2) { throw new Error('Agent session did not close both turns')}console.log({ runId, closedTurns: closedTurns.length })await looms.stop()Approve or reject#
The workflow checks the gate result before marking the change shipped. Change the decision to reject and expect shipped: false. A gate alone does not enforce that branch.
import { approval, decision, gate, pendingApprovals } from '@swirls/looms/approval'import { isJsonObject } from '@swirls/looms/core'import { createLooms } from '@swirls/looms/runtime'import { defineWorkflow, workflow } from '@swirls/looms/workflow'// Human-in-the-loop: a workflow parks on gate(), then a decision resumes it.// bun run --filter @looms/examples approvalconst review = defineWorkflow({ name: 'review', nodes: [ { id: 'ask', run: (ctx) => ctx.effects(gate({ title: 'Ship this change?' })), }, { id: 'done', deps: ['ask'], run: (ctx) => ({ shipped: isJsonObject(ctx.results.ask) && ctx.results.ask.outcome === 'approve', }), }, ], output: ({ results }) => results.done ?? null,})const looms = createLooms({ modules: [workflow({ definitions: [review] }), approval()],})const { runId, state } = await looms.start(review, {})console.log('after start:', state.status)const pending = await looms.project(runId, pendingApprovals)const approvalId = pending.items.find((item) => item.status === 'pending')?.approvalIdif (!approvalId) { throw new Error('expected a pending approval')}console.log('pending approval:', approvalId, pending.items[0]?.title)const after = await looms.signal(runId, [decision(approvalId, 'approve')])console.log('after decision:', after.status)console.log('output:', after.rootThreadId ? after.threads[after.rootThreadId]?.output : null)await looms.stop()Score, then branch#
Save as evaluator.ts. defineEvaluator asks boolean, choice, and score questions, then a workflow branches on route and reason. The stub needs no key. Hosted TypeSafe Jev, or a later model with the same question API, replaces the stub through vercelEvaluator.
import { z } from 'zod'import { agent, defineAgent } from '@swirls/looms/agent'import { defineEvaluator, evaluations, evaluator } from '@swirls/looms/evaluator'import { createLooms } from '@swirls/looms/runtime'import { defineWorkflow, workflow } from '@swirls/looms/workflow'// Score with defineEvaluator, branch on route/reason, then maybe spawn an agent.// The stub stands in for any evaluation model, including TypeSafe Jev.// bun run --filter @looms/examples evaluatorconst RefundInput = z.object({ amount: z.number(), reason: z.string(),})const scoreRefund = defineEvaluator({ name: 'score-refund', description: 'Cheap typed questions before an expensive review.', input: RefundInput, questions: { needsReview: { type: 'boolean', instructions: 'Should a reasoning model or a human review this refund before it ships?', }, urgency: { type: 'choice', instructions: 'How urgent is this refund?', criteria: { high: 'Customer-visible failure or dispute', normal: 'Needs a look but is not blocking', low: 'Routine, already resolved, or duplicate', }, }, risk: { type: 'score', instructions: 'Score the risk of paying this refund incorrectly.', criteria: ['None', 'Low', 'Moderate', 'High', 'Severe'], }, }, skipModel: (input) => { if (input.amount >= 500) { return { route: 'review', reason: 'skipped' } } return undefined }, route: ({ answers }) => { const needs = answers.needsReview const risk = answers.risk const probability = needs?.type === 'boolean' ? needs.probability : null const score = risk?.type === 'score' ? risk.value : null if (probability === null || score === null || probability < 0 || probability > 1) { return { route: 'review', reason: 'unavailable' } } const auto = probability < 0.1 && score <= 1 const confident = auto || probability >= 0.9 return { route: auto ? 'auto' : 'review', reason: confident ? 'model' : 'uncertain', } },})const reviewer = defineAgent({ name: 'refund-reviewer', instructions: 'Draft a short refund review.', input: RefundInput, runTurn: ({ input }) => ({ message: { role: 'assistant', content: `Review refund of ${input.amount}: ${input.reason}` }, done: true, output: { draft: `Review refund of ${input.amount}` }, }),})const refund = defineWorkflow({ name: 'refund', input: RefundInput, nodes: [ { id: 'score', run: (ctx) => ctx.spawn(scoreRefund, ctx.input), }, { id: 'next', deps: ['score'], run: (ctx) => { const scored = z .object({ route: z.string(), reason: z.enum(['model', 'uncertain', 'skipped', 'unavailable']), }) .safeParse(ctx.results.score) const route = scored.success ? scored.data.route : 'review' const reason = scored.success ? scored.data.reason : 'unavailable' if (route === 'review' || reason === 'uncertain' || reason === 'unavailable') { return ctx.spawn(reviewer, ctx.input) } return { path: 'auto', paid: true } }, }, ], output: ({ results }) => results.next ?? null,})const looms = createLooms({ modules: [ evaluator({ definitions: [scoreRefund] }), agent({ definitions: [reviewer] }), workflow({ definitions: [refund] }), ],})const routine = await looms.start(refund, { amount: 12, reason: 'duplicate charge, already resolved',})console.log('routine:', { status: routine.state.status, output: routine.state.rootThreadId ? routine.state.threads[routine.state.rootThreadId]?.output : null,})const dispute = await looms.start(refund, { amount: 40, reason: 'failed capture, customer dispute',})console.log('dispute:', { status: dispute.state.status, output: dispute.state.rootThreadId ? dispute.state.threads[dispute.state.rootThreadId]?.output : null,})const large = await looms.start(refund, { amount: 900, reason: 'goodwill credit',})console.log('large:', { status: large.state.status, output: large.state.rootThreadId ? large.state.threads[large.state.rootThreadId]?.output : null,})console.log('evaluations:', { routine: (await looms.project(routine.runId, evaluations)).items.map( (item) => `${item.reason}:${item.route}`, ), dispute: (await looms.project(dispute.runId, evaluations)).items.map( (item) => `${item.reason}:${item.route}`, ), large: (await looms.project(large.runId, evaluations)).items.map( (item) => `${item.reason}:${item.route}`, ),})await looms.stop()Workflow with a child agent#
Save as workflow.ts. A node doubles the input, another spawns an agent, and a final node combines the results. Both root and child definitions are registered.
import { z } from 'zod'import { agent, defineAgent } from '@swirls/looms/agent'import { createLooms } from '@swirls/looms/runtime'import { defineWorkflow, workflow } from '@swirls/looms/workflow'// DAG workflow: nodes with deps, and a child agent spawn.// bun run --filter @looms/examples workflowconst echo = defineAgent({ name: 'echo', instructions: 'Echo the user.', input: z.object({ text: z.string() }), runTurn: ({ input }) => ({ message: { role: 'assistant', content: input.text }, done: true, output: { text: input.text }, }),})const pipeline = defineWorkflow({ name: 'pipeline', input: z.object({ n: z.number().default(21) }), nodes: [ { id: 'double', run: (ctx) => ctx.input.n * 2, }, { id: 'spawn', deps: ['double'], run: (ctx) => ctx.spawn(echo, { text: `n=${JSON.stringify(ctx.results.double ?? null)}`, }), }, { id: 'format', deps: ['spawn'], run: (ctx) => ({ doubled: ctx.results.double ?? null, child: ctx.results.spawn ?? null, }), }, ], output: ({ results }) => results.format ?? null,})const looms = createLooms({ // Both modules are required: the workflow spawns an agent child. modules: [agent({ definitions: [echo] }), workflow({ definitions: [pipeline] })],})const { runId, state } = await looms.start(pipeline, { n: 21 })console.log('runId:', runId)console.log('status:', state.status)console.log('output:', state.rootThreadId ? state.threads[state.rootThreadId]?.output : null)console.log( 'threads:', Object.values(state.threads).map((t) => `${t.definitionName}:${t.status}`),)await looms.stop()Workflow graphs#
Workflows accept explicit edges, labeled switch branches, a concurrency limit, and per-node failure policies: fail, retry, skip, or fallback. Scheduling stays deterministic, and nodes outside the selected branch are skipped.
const workflow = defineWorkflow({ name: 'route-order', concurrency: 8, nodes: [ { id: 'route', type: 'switch', run: ({ input }) => ({ type: 'switch', branch: input.priority }), }, { id: 'express', failure: { type: 'retry', maxAttempts: 3 }, run: shipExpress }, { id: 'standard', failure: { type: 'skip' }, run: shipStandard }, ], edges: [ { sourceNodeId: 'route', targetNodeId: 'express', label: 'high' }, { sourceNodeId: 'route', targetNodeId: 'standard', label: 'normal' }, ],})A retried node runs again as a fresh attempt. Skipped nodes still unblock their dependents, and a node that falls back reports its fallback's result to them.
For child iterations, return ctx.map(child, items) to run them one at a time, ctx.fanout(child, items, concurrency) to run them in parallel, or ctx.loop(child, input, { maxIterations }) to re-run the node with each iteration's output in ctx.iteration. Each iteration is a durable child thread with a deterministic ID, and outputs keep input order. Inputs, outputs, and iteration state have configurable UTF-8 byte limits.
Your own execution kind#
Save as custom-thread.ts. This auction registers a new kind, accepts bids, waits for a close event, and returns a winner. It uses neither the agent nor the workflow module. Expected winner: Bob, with a bid of 320 against a reserve of 250.
import { z } from 'zod'import { complete, createKind, defineEventCatalog, defineModule, wait } from '@swirls/looms/core'import { createLooms } from '@swirls/looms/runtime'// Custom thread: createKind stamps the startable definition; defineModule implements the kind.// Neither @swirls/looms/agent nor @swirls/looms/workflow is required.// bun run --filter @looms/examples custom-threadconst AuctionInput = z.object({ item: z.string(), reservePrice: z.number(),})const BidPayload = z.object({ bidder: z.string(), amount: z.number(),})const ClosePayload = z.object({ reason: z.string().optional(),})const WrappedEventSchema = z.object({ type: z.string(), payload: z.unknown().optional(),})const auction = createKind('auction')function defineAuction(name: string) { return auction.define({ name, input: AuctionInput, })}// 1. Declare domain events for the thread to observeconst auctionCatalog = defineEventCatalog('auction', { bid: BidPayload, close: ClosePayload,})const vintageWatch = defineAuction('vintage-watch')interface AuctionState { readonly item: string readonly reservePrice: number readonly highestBid: number readonly highestBidder: string readonly bidCount: number readonly status: 'open' | 'closed'}function applyBid(state: AuctionState, bid: z.infer<typeof BidPayload>): AuctionState { if (state.status !== 'open') { return state } if (bid.amount > state.highestBid) { return { ...state, highestBid: bid.amount, highestBidder: bid.bidder, bidCount: state.bidCount + 1, } } return { ...state, bidCount: state.bidCount + 1 }}// 2. Define the module that implements the 'auction' thread kindconst auctionModule = defineModule( { namespace: 'auction', protocolVersion: '1.0.0', events: auctionCatalog, }, (m) => ({ // Modules carry the definitions they implement definitions: [vintageWatch], threads: { auction: m.thread({ kind: 'auction', input: AuctionInput, initialState: (ctx): AuctionState => ({ item: ctx.input.item, reservePrice: ctx.input.reservePrice, highestBid: 0, highestBidder: 'nobody', bidCount: 0, status: 'open', }), step: (state, event): AuctionState => { switch (event.type) { case 'auction.bid': return applyBid(state, event.payload) case 'auction.close': return { ...state, status: 'closed' } // Protocol events deliver matched domain events that satisfied a wait case 'runtime.wait.satisfied': { const parsedWrapper = WrappedEventSchema.safeParse(event.payload.event) if (!parsedWrapper.success) { return state } const inner = parsedWrapper.data if (inner.type === 'auction.bid') { const parsed = BidPayload.safeParse(inner.payload) return parsed.success ? applyBid(state, parsed.data) : state } if (inner.type === 'auction.close') { return { ...state, status: 'closed' } } return state } default: return state } }, effects: (state) => { if (state.status === 'closed') { const metReserve = state.highestBid >= state.reservePrice return [ complete({ item: state.item, winner: metReserve ? state.highestBidder : null, winningBid: metReserve ? state.highestBid : 0, reserveMet: metReserve, totalBids: state.bidCount, }), ] } // While open, park the thread until a bid or close event arrives return [ wait({ waitId: `bid_wait_${state.bidCount}`, on: { type: ['auction.bid', 'auction.close'] }, }), ] }, }), }, projections: { bids: m.projection({ name: 'bids', shape: z.object({ history: z.array(BidPayload), }), initialState: { history: [] }, reduce(state, event) { if (event.type === 'auction.bid') { return { history: [...state.history, event.payload] } } return state }, }), }, }),)// 3. Stand up a runtime with only the custom auction moduleconst looms = createLooms({ modules: [auctionModule],})// Start the auction runconst { runId, state: initialState } = await looms.start(vintageWatch, { item: '1968 Chronograph', reservePrice: 250,})console.log('started auction run:', runId, 'status:', initialState.status)// Send signals (bids) into the running auctionawait looms.signal(runId, [auctionCatalog.input('bid', { bidder: 'Alice', amount: 200 })])console.log('placed bid from Alice ($200)')await looms.signal(runId, [auctionCatalog.input('bid', { bidder: 'Bob', amount: 320 })])console.log('placed bid from Bob ($320)')// Close the auctionconst finalState = await looms.signal(runId, [ auctionCatalog.input('close', { reason: 'Going once, going twice, sold!' }),])console.log('final run status:', finalState.status)const rootThread = finalState.threads[finalState.rootThreadId!]console.log('auction outcome:', rootThread?.output)// Query the projection for the full bid historyconst history = await looms.project(runId, auctionModule.projections.bids)console.log('bid history projection:', history.history)await looms.stop()Build a complete app#
Connect a model, mount an authorized gateway and React UI, or persist and recover a review. Use the restart test to verify both decisions.