Skip to content
Looms
Documentation
On this page

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#

bash
mkdir looms-startercd looms-starternpm init -ynpm install @swirls/looms zod

The 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.

ts
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.

ts
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#

bash
bun durable-review.ts start

The 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.

bash
bun durable-review.ts inspect YOUR_RUN_ID

This is a new process. It should show one pending review and the agent/tool events recorded by the first process.

Resume with a decision#

bash
bun durable-review.ts approve YOUR_RUN_ID

Expect 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:

bash
bun durable-review.ts reject ANOTHER_RUN_ID

Expect 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#

These source files are shared with the runnable examples and tested across separate processes. The full examples directory includes smaller patterns.