@formfactory-dev/workflows
v0.8.2
Published
Runtime SDK for Form Factory workflow scripts (agent, sandbox, worktree).
Maintainers
Readme
@formfactory-dev/workflows
Runtime SDK for Form Factory workflow scripts. Agent providers, sandbox providers, worktree management, and the runner for claude invocations with idle timeouts and JSONL session capture.
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_SANDBOX === "docker" ? 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.
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).
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.
