@formfactory-dev/workflows
v0.10.0
Published
Runtime SDK for Form Factory workflow scripts (agent, sandbox, worktree).
Maintainers
Readme
@formfactory-dev/workflows
Runtime SDK for Form Factory workflow scripts. A small agent harness delivering the five capabilities any agent-workflow substrate has to provide — durable steps, parallel work, event-driven control flow, persistent external state, structured observability — plus integrations a workflow drives (Claude Code / Codex agent providers, docker / no-sandbox providers, git worktree management) and typed composites for common orchestration patterns.
Install
pnpm add -D @formfactory-dev/workflowsRequires Node.js 24+.
ff init adds this as a dev dependency automatically. Install directly only when authoring workflows outside an ff init repo.
Usage
A workflow is a TypeScript file at .ff/workflows/<name>.ts that
imports the SDK and orchestrates one or more agent runs:
import {
claudeCode,
createWorktree,
docker,
noSandbox,
} from "@formfactory-dev/workflows"
const [ticket] = process.argv.slice(2)
const wt = await createWorktree({
branch: ticket.toLowerCase(),
baseBranch: "main",
})
try {
await wt.run({
agent: claudeCode({ model: "claude-opus-4-7" }),
sandbox: process.env.FF_WORK_SANDBOX === "1" ? docker() : noSandbox(),
promptFile: ".ff/prompts/work.md",
promptArgs: { TICKET: ticket },
idleTimeoutSeconds: 600,
})
} finally {
await wt.cleanup()
}Run it: pnpm run work PROJ-1 (ff init writes the script entry). For the scaffolding flow see @formfactory-dev/cli's README.
Everything is exported from the package root — import { ... } from "@formfactory-dev/workflows". GitHub helpers (listMyOpenPrs, fetchUnresolvedThreads, etc.) live in @formfactory-dev/toolkit.
API reference
run(options)
Render a prompt, spawn an agent, watch the idle timer, capture the JSONL
session into ~/.claude/projects/<encoded-cwd>/<id>.jsonl so
claude --resume <id> works.
type RunOptions = {
agent: AgentProvider // claudeCode({ ... })
sandbox?: SandboxProvider // docker() | noSandbox(); default noSandbox()
cwd?: string // default process.cwd()
repoRoot?: string // for sandbox env forwarding; default = cwd
prompt?: string // inline (no substitution)
promptFile?: string // file with {{KEY}} placeholders
promptArgs?: Record<string, string | number | boolean>
idleTimeoutSeconds?: number // kill after N seconds with no output
signal?: AbortSignal
dryRun?: boolean // render argv without spawning
name?: string // labels stderr lines as `[name] ...` and is
// forwarded as the third arg to onText/onToolCall
onText?: (text: string, name?: string) => void
onToolCall?: (toolName: string, argSummary: string, name?: string) => void
}Returns either { kind: "dry-run", prompt, argv, stdin? } or { kind: "ran", exitCode, session?, stdout, commits, usage? }.
stdout: string— assembled prose output. The agent's finalresultevent wins when seen; otherwise the runner concatenates streamedtextevents. Workflows that need to parse structured output (e.g. a planner emitting JSON wrapped in<plan>...</plan>) read this.commits: { sha: string }[]— commits added tocwd'sHEADduring the run, oldest first.usage?: TokenUsage—{ inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens }from the last assistant event in the session JSONL. Undefined when the session wasn't captured.
run() throws tagged errors on failure (instanceof-checkable):
IdleTimeoutError— no agent output foridleTimeoutSeconds. Awarning: agent idle for N minute(s)line is written to stderr each minute before the kill (suppressed whenonText/onToolCallcallbacks are set).AgentExitError— non-zero exit code; carries.exitCode.PromptResolutionError— bad options (both/neither ofprompt/promptFile, missing prompt file, non-positiveidleTimeoutSeconds).- The
AbortSignal'sreasonpropagates verbatim on abort —run()does not wrap it.
interactive(options)
TUI takeover — hands stdin/stdout/stderr to the agent. Defaults to noSandbox() for the common "ask Claude something quick on this checkout" case.
createWorktree(options)
Provision a git worktree and get back a handle for chained runs:
const wt = await createWorktree({
branch: "proj-1",
baseBranch: "main",
copyToWorktree: ["node_modules"], // optional; staged before any agent run
})
try {
await wt.run({ agent: claudeCode(...), sandbox: docker(), promptFile: "..." })
await wt.run({ ... })
} finally {
await wt.cleanup()
}The handle exposes path, branch, reused, run(opts),
interactive(opts), cleanup(). reused is true when an existing
worktree was attached to instead of created.
claudeCode(options)
claudeCode({
model?: string // --model flag
maxTurns?: number // --max-turns
binPath?: string // default "claude" (PATH lookup)
})The runner pipes the rendered prompt to the agent's stdin instead of passing it as a -p value — Claude's CLI (like all commander-style parsers) rejects values starting with -/--/---, which YAML-frontmatter prompts do.
codex(options)
Codex CLI factory, at full feature parity with claudeCode():
import { codex, run } from "@formfactory-dev/workflows"
const result = await run({
agent: codex({ model: "gpt-5-codex", effort: "high" }),
prompt: "do the thing",
})
// result.session?.id — the Codex thread id (from `thread.started`)
// result.session?.jsonlPath — captured JSONL under ~/.cache/ff-cli/sessions/codex/
// result.usage — token totals summed across all turn.completed eventsBoth agents share the same AgentProvider shape (AgentLauncher +
AgentEventSource) so workflows pick either per run() call. The seams
that differ are documented honestly:
- Session capture lands in different places. Claude Code writes to
~/.claude/projects/<encoded-cwd>/<sessionId>.jsonlso the operator canclaude --resume <id>on the host. Codex writes to~/.cache/ff-cli/sessions/codex/<encoded-cwd>/<threadId>.jsonlbecause Codex's own resume mechanism (codex exec resume <threadId>) reads from its internal session store (CODEX_HOME, default~/.codex/), not from our captured file. Our Codex JSONL is purely an observability artifact. - Resume invocation. For Claude,
claude --resume <id>"just works" onceresult.sessionis populated. For Codex, re-spawn with["exec", "resume", <threadId>](a separaterun()call with a custom argv builder, or a future helper).
detectRepo(options?)
detectRepo({
cwd?: string // default process.cwd()
remote?: string // default "origin"
})owner/repo slug from the named git remote, falling back to the cwd
basename when the remote is missing or unparseable. Handy for
{{REPO}} in prompt templates.
docker(options) / noSandbox()
docker({
image?: string // when omitted, lazily build from .ff/sandbox/Dockerfile
network?: string // default = docker bridge
})When options.image is omitted, docker() builds an image from <repoRoot>/.ff/sandbox/Dockerfile on first use and tags it ff-workflow-sandbox:<sha12> (sha of the full Docker build context — Dockerfile plus every sibling file it could COPY/ADD). Edits to any context file produce a new tag and a fresh build automatically.
The policy is locked down inside the SDK (non-root user, all caps dropped, no new privileges; bind-mount matches host path so claude --resume works on the host).
GitHub helpers
Live in @formfactory-dev/toolkit. All shell out to gh / gh api graphql, reusing the operator's existing auth.
import {
listMyOpenPrs,
fetchUnresolvedThreads,
replyToThread,
resolveThread,
addReaction,
} from "@formfactory-dev/toolkit"
const prs = await listMyOpenPrs()
const threads = await fetchUnresolvedThreads(prs[0].number)
await replyToThread(threads[0].id, "Addressed in <sha>: <one-liner>")
await addReaction(threads[0].comments[0].id, "+1")
// Optional — `revise.md` defaults to leaving threads open as audit trail.
await resolveThread(threads[0].id)These power the bundled revise.ts workflow. gh calls bound to a 30 s timeout; reviewThreads paginate; listMyOpenPrs sets --limit 1000 (gh defaults to 30).
Harness capabilities
The SDK is a small agent harness: it delivers the five capabilities any agent-workflow substrate has to provide, plus the integrations a workflow drives (agents, sandboxes, worktrees). Higher-level patterns (review loops, panels, optimisers, fan-out fan-in) are exported as composites — typed callable functions you import from this package — see the Composites section below.
| Capability | Modules | What it gives you |
| ------------------------- | ------------------------- | --------------------------------------------------------------------------- |
| Durable steps | step, run | Named, replayable units of work. Rerunning a workflow skips completed work. |
| Parallel work | parallel | Structured concurrency: settle / fail-fast / cancel-siblings. |
| Event-driven control flow | signal, timer, saga | Typed rendezvous, journaled sleep, LIFO compensation. |
| Persistent external state | journal | Append-only log of step outcomes, signals, run lifecycle. |
| Structured observability | recorder | Observation seam — stderr printer, JSONL capture, usage rollup. |
The architectural rationale lives in docs/adr/. The
3-layer architecture (primitives / composites / workflows) is
described in docs/architecture.md. The
vocabulary is in GLOSSARY.md.
A workflow with durable steps
import {
claudeCode,
createRunContext,
defaultRecorder,
jsonlJournal,
newRunId,
parallel,
run,
step,
withRunContext,
} from "@formfactory-dev/workflows"
const runId = process.env.FF_RUN_ID?.trim() || newRunId()
const ctx = createRunContext({
runId,
recorder: defaultRecorder(),
journal: jsonlJournal({ runId }),
})
await withRunContext(ctx, async () => {
await parallel(
tickets,
(ticket) =>
step(`work ${ticket}`, async () => {
const plan = await step(`plan ${ticket}`, () => planTicket(ticket))
for (const subtask of plan) {
await step(`implement ${subtask.branch}`, () =>
implementSubtask(ticket, subtask),
)
}
}),
{ concurrency: 4, policy: "settle" },
)
})Rerun with FF_RUN_ID=<id> to resume — completed steps are skipped and
their recorded output is replayed. Step outputs must be JSON-serialisable
(plain objects, arrays, primitives).
step(name, body, options?)
async function step<T>(
name: string,
body: (ctx: RunContext) => Promise<T> | T,
options?: {
ctx?: RunContext // override ambient withRunContext scope
idempotencyKey?: string // override the name-derived stepId
retryable?: (err: unknown) => boolean
},
): Promise<T>Identifies steps by parentStepId/name (or idempotencyKey). Duplicate
step names at the same level throw — disambiguate by naming or include
ticket/index data in the name. Failures record step_failed rows; on
rerun, non-retryable failures throw ResumedStepFailure without invoking
the body. Mark transient errors with retryable to allow re-invocation.
parallel(items, worker, options)
function parallel<T>(
items: T[],
worker: (item: T, signal: AbortSignal) => Promise<void> | void,
options: {
concurrency: number
policy?: "settle" | "fail-fast" | "cancel-siblings" // default "settle"
signal?: AbortSignal
},
): Promise<{ ok: T[]; failed: Array<{ item: T; error: unknown }> }>Workers receive a per-call AbortSignal derived from the parent. Under
fail-fast and cancel-siblings, the first failure aborts that signal so
in-flight workers can bail with cleanup. fail-fast rejects the parent
promise with the first error; cancel-siblings resolves with the partial
result. The legacy (item) => Promise<void> worker shape is still
accepted.
signal(name) / emit(ref, value) / waitFor(ref)
const approval = signal<{ approved: boolean }>("approve")
await emit(approval, { approved: true }) // writes signal_emitted
const decision = await waitFor(approval, { timeoutMs: 60_000 })Broadcast semantics: every waitFor matching (name, correlation)
resolves on the latest emission, so fan-out patterns work out of the box.
Sequential queue semantics use distinct correlation per task. Polling
interval defaults to 200ms; the polling loop respects ctx.signal and the
provided timeoutMs.
timer({ id, ms, signal? })
Journaled sleep. On first call records timer_started, sleeps ms,
records timer_done. On resume with the same runId + id: a
completed timer returns immediately; an interrupted one (started but
not done) waits the remainder (max(0, startedAt + ms - now)).
await timer({ id: "rate-limit-cooldown", ms: 30_000 })Abort via signal. The abort's reason rejects the call and
timer_done is not recorded — the next resume continues waiting from
the original startedAt. ms must be a positive finite number; id
must be non-empty.
saga({ id }, body)
Structured compensation. The body receives a ctx.onCompensate(fn)
hook; on body failure the registered compensations run LIFO before the
original error re-throws. The outer saga is journaled as one step(),
so a completed saga skips the body on resume. Compensations themselves
are NOT individually journaled — wrap them in step() if you need
durability.
await saga({ id: "deploy" }, async (ctx) => {
await step("provision", () => provision())
ctx.onCompensate(() => step("rollback-provision", () => deprovision()))
await step("publish", () => publish())
ctx.onCompensate(() => step("rollback-publish", () => unpublish()))
})If a compensation throws, remaining compensations still run and the
collected errors are attached to a SagaCompensationError with
originalError + compensationErrors[] properties.
Journal / Recorder / RunContext
const ctx = createRunContext({
runId: newRunId(),
recorder: defaultRecorder({ name: "myflow" }),
journal: jsonlJournal({ runId }), // or inMemoryJournal() for tests
})
await withRunContext(ctx, async () => {
/* ... */
})Journallives at~/.cache/ff-cli/runs/<runId>.jsonlby default. Passdirto override.Recorderdefaults to stderr printing + JSONL capture + token-usage rollup.silentRecorder()is available for tests;defaultRecorder()accepts the sameonText/onToolCallhooks the legacyRunOptionsdid.RunContextpropagates viaAsyncLocalStorage—step(),signal(),emit(),waitFor()pick it up automatically insidewithRunContext.
Composites
Higher-level orchestration patterns ship as composites — typed callable functions that compose primitives. Import directly from this package:
import {
kanban,
reviewLoop,
gatherAndSynthesize,
} from "@formfactory-dev/workflows"Each composite encodes a pedagogical pattern (when to use, when not to)
in its JSDoc and exposes a typed opts/result shape. Composites live
under packages/workflows/src/composites/<category>/; categories
match the functional cut in
GLOSSARY.md § Composites, by function.
| Composite | Shape |
| --------------------- | --------------------------------------------------------------------- |
| approvalGate | risk assess → optional waitFor approval → execute |
| checkSuite | N checks in parallel → verdict agent aggregates |
| classifyAndRoute | classifier labels items → specialists handle each in parallel |
| contentPipeline | sequential refinement stages (outline → draft → edit) |
| debate | proposer/opponent alternate for N rounds → judge verdict |
| decisionTable | classifier → flat rule table (first-match / all-match) |
| driftDetector | capture → compare to baseline → alert when drifted |
| escalationChain | sequential tiers with confidence thresholds → optional human fallback |
| gatherAndSynthesize | N source-specific gathers in parallel → synthesiser |
| kanban | items walk ordered columns; each (item, column) is its own step |
| mergeQueue | sequentially merge a list of branches with skip-on-conflict |
| optimizer | generator/evaluator loop; returns best-seen candidate |
| panel | role-specific reviewers run in parallel → moderator synthesis |
| poller | repeatedly check a condition until satisfied |
| ralph | loop one agent on a freeform prompt until stopped or maxIterations |
| reviewLoop | producer/reviewer iteration until approved |
| runbook | sequential ops with per-step risk gating via approval signals |
| scanFixVerify | scan → parallel fix → verify; retry until clean |
| supervisor | boss plans → workers run in parallel → boss re-delegates failures |
The architectural rationale and full layering model are in
docs/architecture.md.
Prompt template substitution
When promptFile is set, {{KEY}} placeholders are replaced with values from promptArgs. Inline prompt: is passed through verbatim — combining promptArgs with inline prompt is rejected.
Live progress
By default the runner mirrors Claude's text to stderr and prints → <ToolName> <preview> per tool call (per-tool display field: Bash → command, Read → file_path, etc.; truncated at 200 chars).
Set RunOptions.name to prefix every line [name] … for parallel runs sharing a terminal. The value is sanitised (ANSI + control characters stripped), so name is safe to derive from external strings like Jira ticket titles.
Pass onText / onToolCall to take over rendering. Either hook suppresses the default printer and receives name as a third argument. JSONL capture to ~/.claude/projects/... runs regardless.
Sandbox auth
Create <repoRoot>/.ff/.env (gitignored) with either:
CLAUDE_CODE_OAUTH_TOKEN= # from `claude setup-token` — reuses your subscription
ANTHROPIC_API_KEY= # pay-as-you-go alternativeEmpty values fall through to process.env. Keys present in the file
form the allowlist of vars forwarded into the sandbox.
Publishing
Published via pnpm publish from GitHub Actions using OIDC (the npm scope is configured as a "trusted publisher" tied to this repo and the release workflow — local publishes are rejected). pnpm publish rewrites catalog: and workspace: protocol references in package.json to concrete version ranges before upload, so consumers installing via npm, yarn, pnpm, or bun see normal version strings.
License
MIT — see LICENSE.
