Debugger
The debugger lists a host's definitions, starts runs, and shows each run's thread tree, event log, state before and after each event, and projections. It uses the same HTTP API as your application.
Serve it from your host#
On Bun or Node, pass debuggerUi() to createLooms and open /debugger on that host. The page includes views for agents, workflows, and approvals.
import { createLooms } from '@swirls/looms'import { debuggerUi } from '@swirls/looms/debugger/server'createLooms({ modules, debugger: debuggerUi(), serve: true,})pathsets the mount path. It defaults to/debuggerand cannot overlap an API route such as/runs.rootpoints at a built copy of the debugger app and defaults to the copy in the package. If the page responds with "Debugger UI is not built",roothas noindex.html.
debuggerUi() reads the app from disk. For Cloudflare Workers, see Durable Object hosts.
Control access#
Loading the page is a read of the route named debugger. The page then reads definitions, runs, and event streams. Starting, signaling, and waking a run are write routes. Chat messages and cancellation are signals. Without authorize, all of these are open. Leave them open only on a trusted local machine.
The debugger sends no Authorization header, so bearer tokens on reads or writes lock it out. Check a session cookie instead:
import { Effect } from 'effect'import { authAllowed, authDenied, createLooms, defaultAuthorize, type Authorize } from '@swirls/looms'import { debuggerUi } from '@swirls/looms/debugger/server'const authorize: Authorize = (request, route) => { if (route.access !== 'read' && route.access !== 'write') { return defaultAuthorize(request, route) } // readSession is your application's cookie session lookup. return Effect.promise(() => readSession(request)).pipe( Effect.map((session) => (session?.isStaff ? authAllowed : authDenied(401, 'Sign in first'))), )}createLooms({ modules, authorize, debugger: debuggerUi(), serve: true })See authentication and tenancy for per-run ownership checks.
Add views for your modules#
Without a plugin, the debugger shows your module's events by type, its projections as JSON, and a form built from each definition's input schema. A DebuggerPlugin replaces those defaults with your own summaries and components. Export it from a /debugger subpath so a Worker or other headless host can import the module root without React.
import { jsonFields, jsonText, type DebuggerPlugin } from '@swirls/looms/debugger'import { invoices } from '../projections'import { BillingRun } from './billing-run'import { InvoicesView } from './invoices-view'export const billingDebugger: DebuggerPlugin = { name: 'billing', families: [ { family: 'billing', color: 'oklch(0.78 0.1 175)', summarize: (event) => event.type === 'billing.invoice.paid' ? { title: 'invoice paid', detail: jsonText(jsonFields(event.payload).invoiceId) } : undefined, }, ], workspace: { kinds: ['billing'], component: BillingRun }, projections: [{ name: invoices.name, component: InvoicesView }],}familiesclaims events whose type starts with<family>., or withprefixwhen set. The longest matching prefix supplies the color and summary. Whensummarizereturnsundefined, the row shows the event type and a compact payload.workspacerenders the middle column for definitions whosekindis listed. Setmatcheswhen only some definitions of that kind should use it. It receives the definition, the selected run ID, andonStartedfor runs it starts. Definitions it does not claim keep the schema form for the whole run.projectionsnames a projection and a component. The component receives the run ID and reads that projection itself.
The app that debuggerUi() serves is prebuilt with the agent, workflow, and approval plugins only. To see your own plugin, render the debugger in your application.
Render it in your application#
DebuggerShell is the same component the hosted page renders. Pass the plugins you want and keep the selection in your router so reloads and shared links open the same run.
import { agentDebugger } from '@swirls/looms/agent/debugger'import { approvalDebugger } from '@swirls/looms/approval/debugger'import { DebuggerShell, type DebuggerSelection } from '@swirls/looms/debugger'import { workflowDebugger } from '@swirls/looms/workflow/debugger'import { billingDebugger } from './billing/debugger'const plugins = [agentDebugger, workflowDebugger, approvalDebugger, billingDebugger]export function DebuggerPage({ selection, onSelectionChange }: { selection: DebuggerSelection onSelectionChange: (selection: DebuggerSelection) => void}) { return ( <div className="h-dvh"> <DebuggerShell plugins={plugins} selection={selection} onSelectionChange={onSelectionChange} endpoint="/api/looms" /> </div> )}selectionholdskind,name,run,thread, andseq. Map them to URL search parameters.endpointis prepended to every API path. It defaults to the page origin. Looms does not send CORS headers, so serve the API from the page's origin, directly or through a proxy.- The shell fills its parent. Give the parent a height.
- The shell uses
localStorageandEventSource. Render it only in the browser, for example by turning off server rendering for that route.
The styles need Tailwind CSS v4. Import the debugger stylesheet after Tailwind. It already scans the debugger and the built-in module views. Add an @source line, relative to your stylesheet, for each of your own plugins:
@import 'tailwindcss';@import '@swirls/looms/debugger/styles.css';@source '../src/billing/debugger';The stylesheet defines a dark theme with shadcn/ui variable names such as --background, --card, and --border. It defines them in a cascade layer, so variables your app sets on :root outside a layer take precedence. A shadcn/ui app's theme applies to the debugger without changes.
Durable Object hosts#
debuggerUi() reads the app from the installed package, and a Worker bundle does not include those files. The Durable Object router also returns 501 for GET /runs and does not route /definitions, because neither belongs to a single run. A debugger pointed at a Worker has no run types to start and no runs to search.
To start and inspect runs during development, load the same modules into a local Bun host with debuggerUi(). The hosting guide covers the Bun setup.