Run, stop, resume
Build an agent that drafts a release announcement, then let a workflow wait for a human decision. Exit the process, start a new one, and approve or reject the same run. Its history and pending review survive.
This tutorial uses Bun 1.3 or later and local SQLite. It makes no network calls: the agent uses a deterministic turn function and publishing is simulated. The same definitions can use a real model and a production actor host.
Create a project#
mkdir looms-startercd looms-starternpm init -ynpm install @swirls/looms zodThe package installs from npm; Bun executes the TypeScript and provides SQLite. Pin @swirls/looms deliberately and commit your lockfile. Node alone cannot execute the Bun SQLite example.
Define the work#
Save this as review-definition.ts. The workflow spawns a writer agent with a tool, asks for a decision, and checks the outcome before publishing. A rejection completes the workflow with published: false.
import { z } from 'zod'import { agent, defineAgent, defineTool } from '@swirls/looms/agent'import { approval, gate } from '@swirls/looms/approval'import { defineWorkflow, workflow } from '@swirls/looms/workflow'// A deterministic agent makes the first run reproducible without an API key.// Replace runTurn with a model adapter when you are ready to use an LLM.const draftMessage = defineTool({ name: 'draft_message', description: 'Draft a release announcement for review.', input: z.object({ topic: z.string() }), handler: ({ topic }) => ({ text: `Ready for review: ${topic}` }),})export const writer = defineAgent({ name: 'writer', instructions: 'Draft an announcement, then return it for human review.', input: z.object({ topic: z.string() }), tools: [draftMessage], maxTurns: 3, runTurn: ({ input, turn }) => turn === 1 ? { message: { role: 'assistant', content: 'Preparing a draft.' }, toolCalls: [{ id: 'draft', name: 'draft_message', arguments: input }], } : { message: { role: 'assistant', content: `Ready for review: ${input.topic}` }, done: true, output: { text: `Ready for review: ${input.topic}` }, },})const approved = z.object({ outcome: z.literal('approve') })export const release = defineWorkflow({ name: 'release-v1', input: z.object({ topic: z.string() }), nodes: [ { id: 'draft', run: (ctx) => ctx.spawn(writer, ctx.input) }, { id: 'review', deps: ['draft'], run: (ctx) => ctx.effects(gate({ title: `Publish ${ctx.input.topic}?` })), }, { id: 'publish', deps: ['review'], run: (ctx) => { // A gate waits for a decision. Only an explicit approval permits publishing. if (!approved.safeParse(ctx.results.review).success) { return { published: false, reason: 'not-approved', simulated: true, draft: null } } // A simulated publication: no network request or external side effect. return { published: true, reason: 'approved', simulated: true, draft: ctx.results.draft ?? null, } }, }, ], output: ({ results }) => results.publish ?? null,})export const modules = [ agent({ definitions: [writer] }), workflow({ definitions: [release] }), approval(),]Add durable storage#
Save this as durable-review.ts. SQLite holds the event log and snapshots; each invocation opens the same file. Run one command at a time—this is a single-process embed, not a multi-writer server.
import { mkdir } from 'node:fs/promises'import { dirname } from 'node:path'import { decision, pendingApprovals } from '@swirls/looms/approval'import { bunSqliteEventStore } from '@swirls/looms/core/bun-sqlite'import { createLooms } from '@swirls/looms/runtime'import { modules, release } from './review-definition'const path = process.env.LOOMS_DB ?? './.looms/review.sqlite'await mkdir(dirname(path), { recursive: true })const looms = createLooms({ modules, store: bunSqliteEventStore({ path }) })const [command = 'start', runId] = Bun.argv.slice(2)try { if (command === 'start') { const result = await looms.start(release, { topic: 'Looms launch' }) console.log(JSON.stringify({ runId: result.runId, status: result.state.status })) console.log('The process now exits. Run inspect, approve, or reject with this runId.') } else { if (!runId) { throw new Error('Usage: bun durable-review.ts <inspect|approve|reject> <runId>') } if (command === 'approve' || command === 'reject') { const reviews = await looms.project(runId, pendingApprovals) const pending = reviews.items.find((item) => item.status === 'pending') if (!pending) { throw new Error('No pending review. Inspect the run before sending another decision.') } await looms.signal(runId, [decision(pending.approvalId, command)]) } else if (command !== 'inspect') { throw new Error(`Unknown command: ${command}`) } const state = await looms.getRun(runId) const root = state.rootThreadId ? state.threads[state.rootThreadId] : undefined console.log(JSON.stringify({ runId, status: state.status, output: root?.output }, null, 2)) console.log(JSON.stringify(await looms.project(runId, pendingApprovals), null, 2)) console.log('Events:', (await looms.getEvents(runId)).map((event) => event.type).join(' → ')) }} finally { await looms.stop()}Start and stop#
bun durable-review.ts startThe command prints a runId and exits. Copy that ID into the next commands. The pending approval is durable even when an aggregate run status still reads running; inspect the approval projection to see what needs attention.
bun durable-review.ts inspect YOUR_RUN_IDThis is a new process. It should show one pending review and the agent/tool events recorded by the first process.
Resume with a decision#
bun durable-review.ts approve YOUR_RUN_IDExpect status: "completed" and published: true in the output. Inspect it again: the result is still there, with no need to rerun the writer.
Start another run and reject it instead:
bun durable-review.ts reject ANOTHER_RUN_IDExpect published: false. A gate waits for a decision; it does not automatically treat rejection as an execution error. Always check the result before a consequential action.
Make it yours#
- Connect a real model and keep the review deterministic.
- Define a custom thread kind with its own state machine.
- Build a live React view of the same history.
- Deploy to Cloudflare Durable Objects with one actor per run.
These source files are shared with the runnable examples and tested across separate processes. The full examples directory includes smaller patterns.