npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@automatalabs/workflows

v0.29.0

Published

The programmatic **SDK** for AgentPrism — run dynamic, multi-agent **workflow scripts** (`agent()` / `parallel()` / `pipeline()` / …) over real coding-agent backends through the [Agent Client Protocol](https://agentclientprotocol.com) (ACP).

Readme

@automatalabs/workflows

The programmatic SDK for AgentPrism — run dynamic, multi-agent workflow scripts (agent() / parallel() / pipeline() / …) over real coding-agent backends through the Agent Client Protocol (ACP).

You author a small JavaScript script (a string), the engine runs it in a deterministic, journaled, resumable realm, and every agent() call inside it is fanned out to a pooled ACP backend — Claude (claude-agent-acp), Codex (codex-acp), OpenCode (opencode acp), or a registered custom ACP agent — driving the actual subprocess to completion.

This package is the canonical SDK that the stdio MCP server @automatalabs/mcp-server is built on. If you want to expose a workflow tool to an MCP host (Claude Code, Zed, …), use that package; if you want to embed the runner in your own program, use this one.

It is a programmatic library, not an MCP stdio server. It is a thin facade over the engine + ACP packages and adds one convenience helper, runDynamicWorkflow, which defaults the agent backend to ACP. The ACP layer does use @modelcontextprotocol/sdk internally when it hosts the optional StructuredOutput tool for eligible agents; consumers still interact through this SDK's workflow/runner APIs rather than MCP server schemas.


Install

pnpm add @automatalabs/workflows

The Claude and Codex ACP servers ship as transitive dependencies and are spawned on demand. OpenCode is host-resolved: install opencode-ai or make opencode available on PATH before routing a call to it.


Requirements

  • Node.js ≥ 22.
  • Backend auth — the SDK spawns the ACP backend as a child process that inherits your process.env, so it uses whatever credentials those agents already use:
    • Claude — a logged-in Claude Code install (~/.claude) or ANTHROPIC_API_KEY in the environment.
    • Codex — a logged-in Codex install (~/.codex).
    • OpenCode — credentials configured for the provider OpenCode will use.

You only need auth for the backend(s) your scripts actually route to. The default backend is Claude (override with AGENTPRISM_DEFAULT_BACKEND; see Backend selection).


Core API

a) runDynamicWorkflow(script, opts?) — run a script to a terminal result

The one-call entry point. It builds a one-off WorkflowManager whose agent backend defaults to createAcpRunner() and runs the script to a terminal WorkflowRunResult. It never throws for an ordinary pause/fail/abort — read result.status directly.

import { runDynamicWorkflow } from "@automatalabs/workflows";

const script = `
  export const meta = {
    name: "repo-scan",
    description: "describe a repo as JSON, two ways in parallel",
    phases: [{ title: "Fan" }],
  };

  const SCHEMA = {
    type: "object",
    additionalProperties: false,
    required: ["repo", "fileCount"],
    properties: { repo: { type: "string" }, fileCount: { type: "number" } },
  };

  phase("Fan");
  log("scanning " + args.repo);
  return await parallel([
    () => agent("Report this repo as JSON {repo, fileCount}.", { label: "a1", schema: SCHEMA }),
    () => agent("Report this repo as JSON {repo, fileCount}.", { label: "a2", schema: SCHEMA }),
  ]);
`;

const run = await runDynamicWorkflow(script, { args: { repo: "agentprism" } });

run.status;      // "completed" | "paused" | "failed" | "aborted"
run.result;      // [{ repo, fileCount }, …] — the script's return value (schema-validated)
run.tokenUsage;  // { input, output, total, cost, … } | undefined
run.runId;       // stable id; pass back to resume a paused run from its journal

Options (RunDynamicWorkflowOptions):

| field | type | meaning | |----------|----------------|---------| | args | unknown | The value handed to the script's args global. | | cwd | string | Base working directory for the run (e.g. the project root): every subagent session runs here (a per-agent agent({ cwd }) or worktree isolation overrides it), worktrees branch from it, and agentType definitions are scanned from it. Omitted ⇒ process.cwd(). | | runner | AgentRunner | Swap the backend (or stub it in tests). Omitted ⇒ createAcpRunner(). | | exec | ExecOptions | Per-run controls forwarded to the manager: tokenBudget, agentTimeoutMs, concurrency, agentRetries, signal, onProgress, confirm, checkpointReplies, … | | allowScriptBackends | boolean \| callback | Approve the commands declared in meta.backends; declarations are inert without host approval. | | workflows | string \| string[] \| WorkflowDir | Resolve the first argument and nested workflow("name") calls from one or more directories. |

const run = await runDynamicWorkflow(script, {
  args: { repo: "agentprism" },
  exec: {
    tokenBudget: 200_000,
    concurrency: 4,
    onProgress: (snapshot) => console.error(snapshot.doneCount, "/", snapshot.agentCount),
  },
});

Every script must begin with export const meta = { name, description, phases? } as its first statement, and must be deterministicDate.now(), Math.random(), and new Date() are unavailable inside the realm (they would break journal replay on resume).

b) createAcpRunner().run(...) — drive a single agent

Skip the script realm entirely and call one agent directly. The runner is the default ACP AgentRunner. With a schema, run() returns the validated object; without one, it returns the assistant's final text.

import { createAcpRunner } from "@automatalabs/workflows";

const runner = createAcpRunner();           // optional: { size } pool option, default 1
try {
  const data = await runner.run("Summarize this repo as JSON {summary}.", {
    schema: {
      type: "object", additionalProperties: false,
      required: ["summary"], properties: { summary: { type: "string" } },
    },
    model: "opus",          // routes to Claude; e.g. "gpt-5.5" routes to Codex
    cwd: process.cwd(),     // absolute working dir for the agent's session
  });
  // data is the schema-validated object (not text)

  const text = await runner.run("Name this repo in one word.");  // no schema ⇒ string
} finally {
  await runner.dispose();   // close the pooled backend processes when you're done
}

run(prompt, options?) accepts the seam's RunOptions: schema, maxSchemaRetries, model, mode, tier, cwd, instructions, label, toolNames / disallowedToolNames, signal, mcpServers, images, backends, meta / promptMeta (generic ACP _meta passthroughs merged into session/new / session/prompt), baseInstructions / developerInstructions (Codex-only), keepSession, and the out-of-band callbacks onUsage / onModelResolved / onModelFallback / onHistory / onSessionOpen. Token/cost usage is delivered via onUsage (it may never fire — ACP usage is experimental), never via the return value.

Codex session instructions. When the run routes to the Codex backend, baseInstructions replaces Codex's built-in base system prompt and developerInstructions adds developer-role instructions for the session. They ride ACP session/new _meta into Codex thread/start and are ignored by the Claude backend (which has no analog) — unlike instructions, which is folded into the prompt text for either backend.

await runner.run("Cut the release.", {
  model: "gpt-5.5",
  baseInstructions: "You are a release bot. Only touch CHANGELOG.md.",
  developerInstructions: "Prefer conventional-commit summaries.",
});

The ACP server process is pooled and reused across run() calls; each run() opens and closes one session on it. Call dispose() once at shutdown to tear the pool down. Pool size is AcpPoolOptions.size (default 1) or AGENTPRISM_ACP_POOL_SIZE.

Custom backends. Any ACP agent can serve run() / agent() calls — register it by name and route to it with model:

const runner = createAcpRunner({
  backends: { browser: { command: "node", args: ["/abs/browser-acp.js"] } },
});
await runner.run("Verify the checkout flow.", { model: "browser" });

model: "browser/vision-large" additionally selects vision-large from the agent's config-option catalog. The same registry can be declared via AGENTPRISM_BACKENDS (JSON env var; the programmatic option wins per name). A schema is forwarded as turn-level _meta.outputSchema and embedded in the prompt. Eligible HTTP-MCP agents also receive the client-hosted StructuredOutput capture tool; otherwise the result is JSON-parsed from the final message. Every path still goes through the validate/re-prompt ladder.

A workflow script can also declare backends itself via meta.backends (same config shape, keyed by name). Because script-declared backends spawn commands on your machine, they are inert unless you approve them: pass allowScriptBackends: true to runDynamicWorkflow, or a callback (backend) => boolean | Promise<boolean> to decide per backend — an unapproved declaration throws with guidance, and a declined backend aborts the run (it would otherwise silently reroute its agent() calls to the default backend). Host-registered names always win over script declarations. Lower-level callers can thread a pre-approved registry via exec.scriptBackends.

Authentication lifecycle

createAcpRunner() is auth-capable. It exposes describeAuthMethods() / completeAuth() and the runner.auth controller (status, authenticate, logout) while preserving the minimal AgentRunner.run() seam. Existing environment variables and backend-native credential stores are used normally. A direct runner.run() throws WorkflowError { code: "AUTH_REQUIRED" } when no resolver can satisfy the backend; a WorkflowManager catches that code and returns a resumable paused result with redacted authContext.

Pass onAuth and authCapabilities to createAcpRunner() when the embedding host can collect credentials inline. Secret env/meta resolutions live in the runner's in-memory auth store, are redacted from events/errors, and are cleared on logout. Browser/TTY methods still require a host that can actually complete them.

c) WorkflowManager — stateful / resumable runs

runDynamicWorkflow is a thin wrapper over a fresh WorkflowManager. Construct one yourself to keep run state across calls, persist journals, and resume a paused run.

import { WorkflowManager, createAcpRunner } from "@automatalabs/workflows";

const manager = new WorkflowManager({ agent: createAcpRunner() });

const run = await manager.runSync(script, { repo: "agentprism" }, { tokenBudget: 200_000 });

const background = manager.startInBackground(script, { repo: "agentprism" }, { tokenBudget: 200_000 });
console.log(background.runId, manager.getSnapshot(background.runId));
// background.promise resolves only on completion and rejects on pause/failure/abort.

const status = manager.inspectRun(run.runId, {
  lastN: 10,
  labelGlob: "review-*",
  logLines: 20,
});
console.log(status?.status, status?.logTail, status?.calls);

if (run.status === "paused") {
  // resume() reloads the original script, args, cwd, and journal under the SAME runId,
  // then continues in the background. Observe manager events or getRun(runId) for status.
  const accepted = await manager.resume(run.runId);
  console.log(accepted); // true when the paused run was accepted for resume
}

runSync(script, args?, exec?) always resolves to a terminal WorkflowRunResult. A run pauses (rather than fails) on a provider usage limit, ACP authentication requirement, or an explicitly durable checkpoint. An auth pause carries reason: "auth_required" plus a non-secret authContext; complete auth on an auth-capable runner before resuming. A checkpoint with a live ExecOptions.confirm resolves immediately; without one, the default mode still takes default ?? true and headless: "abort" aborts. Only headless: "pause" returns reason: "checkpoint_required" plus checkpointContext; resume with checkpointReplies: { [context.callIndex]: decision } (or a live confirm). The injected answer is journaled and replayed, so a detached run never pauses for a checkpoint unless the author opts in. WorkflowManagerOptions lets you set a default agent, concurrency, cwd, a loadSavedWorkflow resolver (enables nested workflow('name')), a custom persistence implementation, and per-agent timeout/retry defaults.

startInBackground(script, args?, exec?) is detached only for the lifetime of this process and returns { runId, promise: Promise<WorkflowRunResult> }. The facade keeps an ACP event bridge for a per-execution exec.agent until that promise settles, including rejection. Read live state with getRun(), getSnapshot(), or inspectRun(), and subscribe to cumulative tokenUsage events while work is running. Live attempts update snapshot.tokenUsage monotonically; replayed calls add zero.

When exec.resumeJournal is preloaded, the underlying manager sorts and copies the inherited prefix into the child run before its fail-fast initial persistence. Cached replay does not re-emit journal events, but every subsequent live suffix write persists the complete prefix. This makes each new background run ID independently resumable across multiple pause/resume hops. Process loss can stop in-flight work; the next manager recovers a stale durable running record to paused, and an unjournaled in-flight call may run again.

inspectRun(runId, options?) is inherited through this facade and returns the shared WorkflowRunStatus without importing @automatalabs/workflow-engine. It reads the freshest live snapshot first and project-scoped persistence second, never executes the script or acquires a run lease, and returns undefined for an unknown/unreadable ID. The facade also re-exports WorkflowRunInspectionOptions, WorkflowLogTail, WorkflowRunCallStatus, WorkflowRunStatusTruncation, WorkflowRunStatus, and JournalCallMetadata. Inspection defaults to 20 calls and 20 log lines, supports case-sensitive whole-label */?/backslash globs, redacts and compacts previews, and enforces the shared 24,576-byte structured cap. Non-completed terminal execution results carry an immediate redacted final-20 logTail; completed results omit it.

Manager events are Node EventEmitter notifications: agentStart, agentEnd, agentHistory, journal, tokenUsage, log, phase, complete, paused, resumed, stopped, error, and agentEvent. journal emits { runId, entry } for each live journal append, even when file journaling is disabled via journaling: false. agentEvent forwards the live ACP stream from an ACP-capable runner with name, event, and the runner context fields (runId, label, sessionId, backendId) when the event carries them; backend_error is connection-scoped and carries backendId only. ACP session/update traffic is emitted once under its inner discriminant name, while permission/elicitation/session/raw/backend events keep their runner names.

manager.on("agentEvent", ({ runId, label, name }) => console.error(runId, label, name));

Every live ACP-backed agent() call records a non-secret re-attach handle in run.agentSessions. Set agent(..., { keepSession: true }) to skip release-time session/close, then use the runner's loadSession() or resumeSession() host API with that record. Session handles are additive and are also preserved in journals; they do not change the deterministic resume identity.

d) Bring your own backend — implement the AgentRunner seam

AgentRunner is the single, frozen coupling point between the engine and any backend. Implement its one method and inject it anywhere a runner is accepted (runDynamicWorkflow({ runner }), new WorkflowManager({ agent }), or runSync(script, args, { agent })).

import {
  runDynamicWorkflow,
  type AgentRunner,
  type RunOptions,
  type AgentResult,
} from "@automatalabs/workflows";
import type { TSchema } from "typebox";

const echoRunner: AgentRunner = {
  async run<S extends TSchema | undefined>(prompt: string, options?: RunOptions<S>) {
    // schema present ⇒ return the validated object; absent ⇒ return text.
    options?.onUsage?.({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 });
    return `echo: ${prompt}` as AgentResult<S>;
  },
};

const run = await runDynamicWorkflow(script, { runner: echoRunner });

Seam contract (summarized): run() returns the raw value (schema ⇒ validated object, no schema ⇒ string) — never an envelope; usage flows out-of-band via options.onUsage; on failure throw (ideally a WorkflowError so instanceof holds across packages); honor options.signal but do not implement your own timeout (the engine owns timeout/abort). This makes the SDK fully testable without a live agent — pass a stub runner.


Loading workflows from folders — openWorkflowDir

Integrators who keep a versioned folder of workflow scripts don't need to hand-roll readFileSync plumbing or a loadSavedWorkflow resolver:

import { openWorkflowDir, runDynamicWorkflow } from "@automatalabs/workflows";

const flows = openWorkflowDir("./workflows");        // or ["./workflows", teamDir] — first hit wins

flows.list();               // [{ name, file, meta }] — meta parsed per call, browsable by a UI
flows.read("review-pr");    // name → script string; throws with searched dirs + did-you-mean
flows.resolve;              // (name) => string | undefined — IS a loadSavedWorkflow resolver

const run = await runDynamicWorkflow("review-pr", {  // a NAME works when `workflows` is set
  workflows: flows,                                  // also accepts "./workflows" or [dir, dir]
  args: { pr: 42 },
});

Semantics worth knowing:

  • Construction does no I/O — nothing is created, nothing is scanned or cached. Every method reads the filesystem at call time, so a long-lived view always reflects the current working tree (a cached scan would serve stale scripts after a git checkout/pull/save). Missing directories simply contribute nothing.
  • The filename stem is the name (review-pr.workflow.js or review-pr.jsreview-pr), mirroring the agentType registry convention; across dirs the first hit wins, within a dir .workflow.js beats .js.
  • workflows also wires nested calls: with the option set, workflow("<name>") inside the script resolves from the same view. (Without it, runDynamicWorkflow has no saved-workflow resolver and nested names cannot resolve.) A top-level string containing export const meta is always treated as a verbatim script, never a name.
  • Versioning is git's job. A run persists its script content, so resume() replays the exact script that started the run even if the file changed since; an edited file simply cache-misses from the first changed call on the next fresh run.
  • resolve() validates name shape strictly (one flat path segment) — inline nested scripts fall through to verbatim parsing, and path traversal out of the configured dirs is impossible.

Listening in on the live ACP stream (events)

createAcpRunner() returns an AcpAgentRunner with a typed event bus. Subscribe with runner.on(name, listener) to observe the live ACP stream of every run on that runner — streaming assistant text, tool calls, usage, permissions — without touching the run() return value or the AgentRunner seam.

import { createAcpRunner } from "@automatalabs/workflows";

const runner = createAcpRunner();

// ACP `sessionUpdate` discriminants are the event names; the listener payload is typed to each.
runner.on("agent_message_chunk", (e) => {
  if (e.content.type === "text") process.stdout.write(e.content.text); // stream tokens as they land
});
runner.on("tool_call", (e) => console.error(`[${e.label}] tool: ${e.title}`));
runner.on("usage_update", (e) => console.error(`ctx ${e.used}/${e.size} tokens`));

// One catch-all for "everything": fires for EVERY session/update, carrying the raw update.
const off = runner.on("session_update", (e) => console.error(e.update.sessionUpdate));

await runner.run("Refactor this module and run the tests.", { label: "refactor", cwd });
off(); // on()/once() return an unsubscribe thunk; off(name, listener) and removeAllListeners() also exist
await runner.dispose();

Event names. The ACP sessionUpdate discriminants verbatim — user_message_chunk, agent_message_chunk, agent_thought_chunk, tool_call, tool_call_update, plan, plan_update, plan_removed, available_commands_update, current_mode_update, config_option_update, session_info_update, usage_update — plus a few cross-cutting events:

| event | payload | |-------|---------| | session_update | { update } — catch-all for every update, regardless of kind | | permission_pending | { request } — resolver-only; emitted after the request is parked and before the resolver is invoked | | permission_request | { request, outcome } — the final permission outcome returned to the agent | | elicitation_pending | { request } — resolver-only; emitted after the elicitation is parked and before the resolver is invoked | | elicitation_request | { request, outcome } — the final elicitation response returned to the agent | | elicitation_complete | { notification } — a URL elicitation completion notification | | raw_message | { method, message } — a vendor extension notification (e.g. Claude _claude/sdkMessage) | | session_open / session_close | an ACP session opened / was released | | backend_error | { backendId, error } — a pooled backend process crashed |

Context envelope. A pooled runner multiplexes many concurrent runs over one process, so every event (except backend_error) carries { sessionId, backendId, label?, runId? } — filter by label/runId (from the run's RunOptions) to attribute an event to a specific run.

Best-effort. Listeners are observers: a throwing listener is isolated and never breaks the run, the update drain, or sibling listeners.

With runDynamicWorkflow / WorkflowManager. Subscribe at the manager layer: manager.on("agentEvent", ({ runId, label, name, event }) => …). Every agent() call in the script then streams live ACP events through the manager; ACP session/update traffic is forwarded once under name = event.sessionUpdate, so hosts do not receive both the catch-all and the per-discriminant runner event.


The in-script DSL

The orchestration primitives are not importable symbols. They are globals injected into the script's vm realm, available only inside the script string you pass to runDynamicWorkflow / runSync. There is nothing to import to obtain them. Their shapes are documented for editor IntelliSense by the ambient dsl.d.ts shipped with this package (it ships no runtime code).

| global | what it does | |--------|--------------| | agent(prompt, options?) | Run ONE subagent to completion; returns its result (text, or the validated object with options.schema). | | parallel(thunks) | Run an array of thunks (() => Promise) concurrently; resolves in input order. | | pipeline(items, ...stages) | Map items through sequential async stages, concurrently across items. | | workflow(nameOrScript, args?) | Run a saved (or inline) workflow nested in this run, sharing its limiter/budget. | | verify(item, options?) | Adversarial verification panel — N reviewers vote whether item is real/correct. | | judgePanel(attempts, options?) | LLM-judge panel — score candidates against a rubric, return the best. | | loopUntilDry(options) | Repeat a round, collecting deduped new items until it dries up. | | completenessCheck(args, results) | Ask a critic what is still missing. | | retry(thunk, options?) | Bounded retry until until(result) holds. | | gate(thunk, validator, options?) | Validate-and-feed-back loop returning { ok, value, verdict, attempts }; verdict is the raw last validator return on either pass or exhaustion. | | checkpoint(text, options?) | Deterministic, journaled human gate: live confirm, headless default/abort, or opt-in durable headless: "pause". | | phase(title, options?) | Open a named phase (optional soft token sub-budget). | | log(message) | Append a line to the run log. | | args | The input bag passed in via { args }. | | budget | Live token-budget view: budget.total, budget.spent(), budget.remaining(). |

(console.log/info/warn/error route to log too.) Pass these primitives thunks, not promises — parallel([() => agent("a"), () => agent("b")]), not parallel([agent("a"), …]).


Validating scripts — agentprism-workflows validate

The package ships a bin that validates a workflow script without spending tokens or spawning any agent process — no backend auth needed:

npx @automatalabs/workflows validate my-workflow.js --args '{"target":"src/"}'

Two passes: a static parse (the meta literal, syntax, the determinism blocklist), then a dry run — the script executes in the real engine realm while every agent() call is served by an in-process mock AgentRunner that fabricates schema-conforming results. The dry run catches what a parse can't: thunk-vs-promise mistakes, reference errors, broken plumbing between calls. A mock live confirm answers checkpoints with default ?? true, so headless: "pause" dry-runs cleanly; headless: "abort" warns because a truly unattended run would abort. Script-declared meta.backends are treated as approved (with a warning that real runs require approval). The report lists every agent call with its backend attribution, every checkpoint, and warnings; exit codes are 0 valid, 1 parse failure, 2 dry-run failure, 3 usage error.

Flags: --args <json> / --args-file <path>, --workflows-dir <dir> (repeatable — validate by NAME and resolve nested workflow("<name>") calls from your folder), --parse-only, --cwd <dir>, --token-budget <n> (exercise budget-guarded paths; the mock reports 1000 tokens per call), --max-agents <n>, --timeout-ms <n>, --mock-answers <json>, --mock-answers-file <path>, --json. The two mock-answer flags are mutually exclusive.

Mock answers select the final resolved agent label with case-sensitive, whole-label globs: * matches any characters (including : and /), ? matches one character, and \ escapes the next character. Rules are captured once in object-member order and the last matching rule wins, so put broad defaults before narrow exceptions. Raw canonical array-index property names ("0" through "4294967294", with no leading zero) are rejected because JavaScript reorders them; escape a digit to match a numeric label, for example the JSON key "\\10" matches label 10. "01" and "4294967295" are ordinary keys.

A rule is either one reusable JSON answer or { "$sequence": [...] }, a finite sequence consumed only when that rule wins. A raw JSON array is one array-valued answer. Each schema-bearing answer deep-merges over a fresh fabricateFromSchema() base: objects merge recursively, while arrays, null, scalars, and falsy primitives replace. The merged value is checked without coercion. Any answer-caused schema violation fails with SCHEMA_NONCOMPLIANCE; an identical failure inherited from an untouched limitation of the simple fabricator may be accepted with a grouped warning. Schema-less answers must be nonblank strings. Sequences never repeat or fall back: exhaustion fails the dry run, while unconsumed singles/items remain non-fatal and appear in structured unused records plus grouped warnings. Supplying mock answers serializes dry-run agent service for stable FIFO sequence allocation, so this mode is not a concurrency/load simulator and its token-budget admission timing can differ from an unscripted concurrent dry run.

Fixture input is capped at 256 KiB (raw CLI UTF-8 and canonical programmatic JSON), 256 rules, 256 UTF-16 code units per glob, 256 sequence entries, and answer nesting depth 32. Values must be plain JSON data. Reports and fixture errors expose only globs, counters, positions, and schema diagnostics—not answer bodies. A fixture is still handed to workflow code like a real agent result, so the script can deliberately expose it through log() or its return value; do not put credentials or production data in mock-answer files.

The same check is available programmatically. Invalid workflow scripts still resolve to reports; an invalid mockAnswers option is an option-contract error and throws TypeError before parsing:

import { validateWorkflowScript, type MockAnswers } from "@automatalabs/workflows";

const mockAnswers: MockAnswers = {
  "*": { approved: true },
  "refute:*": { real: false },
  "quality:review": {
    $sequence: [
      { ok: false, feedback: "exercise the revision path" },
      { ok: true },
    ],
  },
};
const report = await validateWorkflowScript(script, { args: { target: "src/" }, mockAnswers });
report.ok;                 // parse ok AND dry run completed
report.dryRun?.agentCalls; // calls include mockAnswer: { glob, sequenceIndex?, sequenceLength? }
report.dryRun?.mockAnswers;// normalized rule counters + item-level unused records
report.warnings;           // approval reminders, phase mismatches, headless-abort checkpoints, …

Structured output

Pass a JSON Schema to agent({ schema }) (in a script) or runner.run(prompt, { schema }) (direct) and the result is a validated object instead of text. The backend constrains output natively (Claude outputFormat; Codex strict outputSchema; prompt/tool-assisted JSON for OpenCode and custom agents), then the value is coerced and validated client-side (typebox ConvertCheck); on a miss the runner re-prompts a bounded number of times before failing with a non-recoverable SCHEMA_NONCOMPLIANCE.

A plain JSON Schema object literal works everywhere (this is the only option inside a script — no schema-builder is injected into the realm):

{
  "type": "object",
  "additionalProperties": false,
  "required": ["title", "score"],
  "properties": {
    "title": { "type": "string" },
    "score": { "type": "number" }
  }
}

In TypeScript you may instead build the schema with typebox (Type.Object({ … })) for static result typing. Two helpers convert a typebox schema to the exact wire JSON Schema each backend expects:

import { toJsonSchema, toStrictJsonSchema } from "@automatalabs/workflows";
import { Type } from "typebox";

const schema = Type.Object({ title: Type.String(), score: Type.Number() });
toJsonSchema(schema);        // plain JSON Schema (Claude outputFormat)
toStrictJsonSchema(schema);  // OpenAI-strict-normalized (Codex outputSchema)

Backend selection

The backend for each agent is chosen from its model (preferred) or tier string:

  • Provider prefixanthropic/… or claude/… ⇒ Claude; openai/… or codex/… ⇒ Codex.
  • OpenCode prefix or idopencode/… or opencode ⇒ OpenCode.
  • Bare id — matched by pattern: codex / gpt / openai / o<digit> ⇒ Codex; claude / opus / sonnet / haiku / anthropic ⇒ Claude.
  • No match / no spec — the default backend: AGENTPRISM_DEFAULT_BACKEND (claude, codex, opencode, or any registered custom backend name; default claude).
import { selectBackend } from "@automatalabs/workflows";

selectBackend({ model: "opus" }).id;          // "claude"
selectBackend({ model: "gpt-5.5" }).id;          // "codex"
selectBackend({ model: "anthropic/claude-sonnet" }).id; // "claude"

Within a provider, the model spec selects the concrete model through ACP session config (session/set_config_option, surfaced as SessionHandle.selectModel()). Per-backend pool size is AGENTPRISM_ACP_POOL_SIZE (or AcpPoolOptions.size).


Exports

// ── Run entry & helper ──
runDynamicWorkflow,           // (script, { args?, runner?, exec? }) => Promise<WorkflowRunResult>
runWorkflow,                  // the bare engine run (no status trio)
parseWorkflowScript,          // parse a script's meta + body
validateWorkflowScript,       // token-free parse + mock-runner dry run (the `validate` CLI's core)
fabricateFromSchema,          // the dry run's JSON-Schema value fabricator
formatValidateReport,         // render a ValidateWorkflowReport as CLI text
openWorkflowDir,              // read-only view over folders of workflow scripts (name = filename stem)
WorkflowManager,              // stateful / resumable run manager

// ── ACP backend ──
createAcpRunner,              // () => AcpAgentRunner (the default AgentRunner; has .on(...) events)
AcpAgentRunner,               // class — implements AgentRunner over ACP
InteractiveSession,           // held-open multi-turn ACP session returned by openSession()
selectBackend,                // pick a built-in/custom backend from a model/tier spec
ClaudeBackend, CodexBackend, CustomAcpBackend,
resolveBackendRegistry, BACKENDS_ENV,
AGENT_METHODS, CLIENT_METHODS, ACP_AUTH_REQUIRED_ERROR_CODE,
clientCapabilitiesFor, adaptPromptContent,
toJsonSchema, toStrictJsonSchema,
TypedEventEmitter,            // the tiny typed emitter backing runner.on(...)

// ── Errors ──
WorkflowError, WorkflowErrorCode, isWorkflowError, isProviderUsageLimit, isAuthRequired,

// ── Persistence paths ──
AGENTPRISM_PERSISTENCE_ROOT_ENV,

// ── Types ──
RunDynamicWorkflowOptions, WorkflowRunOptions, AgentOptions, ExecOptions,
MockAnswerJson, MockAnswerSequence, MockAnswerRule, MockAnswers,
ValidatedMockAnswerUse, ValidatedMockAnswerRule, UnusedMockAnswer, ValidatedMockAnswers,
ValidateWorkflowOptions, ValidateWorkflowReport, ValidatedAgentCall, ValidatedCheckpoint,
WorkflowManagerOptions, CheckpointOptions, WorkflowRunResult, WorkflowSnapshot,
WorkflowPathOptions, RunPersistence, RunPersistenceOptions,
AcpPoolOptions, AcpRunnerOptions, AgentRunner, RunOptions, AgentResult, AgentUsage, JournalEntry,
AgentSessionRef, AgentSessionRecord, WorkflowBackendConfig,
InteractiveSessionOptions, InteractiveTurn, PermissionResolver,
AuthResolver, AuthContext, AuthResolution, AuthMethodDescriptor, AuthCapableRunner,
ProviderCapableRunner,        // duck-type gate for the MCP provider tools (providers/list|set|disable)
ClientHandlers, FsHandlers, TerminalHandlers, McpHandlers, AcpSessionContext, NegotiatedCapabilities,
// ACP events: the runner.on(...) surface
AcpRunnerEventMap, AcpEventName, AcpEventListener, AcpEventContext,
AcpSessionUpdate, AcpUpdateKind, AcpPermissionPendingEvent, AcpPermissionEvent,
AcpElicitationPendingEvent, AcpElicitationEvent, AcpElicitationCompleteEvent,
AcpRawMessageEvent, AcpBackendErrorEvent,

(The DSL globals — agent, parallel, pipeline, … — are not exported; they are realm globals documented by the ambient dsl.d.ts.)


Skill for AI agents that write workflows

The repository publishes an agent skill — a self-contained, backend-agnostic authoring guide in the standard SKILL.md format — at skills/agentprism-workflow-authoring/. Install it into whatever coding agent you use (Claude Code, Codex, Cursor, OpenCode, …) with the skills CLI:

npx skills add VikashLoomba/agentprism-workflows

It teaches the full script DSL: routing each agent() call to a different ACP backend inside one script, structured outputs across all backends, checkpoint() gates, budgets, worktree isolation, and the determinism rules that make runs resumable. reference.md alongside it holds the exhaustive option tables.


See also

  • examples/ — runnable examples: repo-triage, a complete standalone project (own package.json, TypeScript host, external workflow scripts) running an autonomous cross-vendor triage; and image-gate, a single gated script with an MCP-wired image producer.
  • @automatalabs/mcp-server — the stdio MCP server built on this SDK. It wraps the same engine + ACP backend behind workflow and conditional auth tools (bin: agentprism-workflow) for any MCP host. Use it when you want the MCP-tool route instead of embedding the runner in code.

License

Apache-2.0