Skip to content

Workflow API ​

Every call a workflow can make today, its options, defaults and refusals. A workflow imports them from agentswf/workflow. This page specifies; the guide teaches. Start at How it works for the words (workflow, run, agent, turn, outcome, harness, TUI, stage, nudge), and follow each section's link to the guide page that explains the idea.

The types, with every field documented, are in packages/contract/src/workflow, and examples/ has complete workflows. A workflow's tests use agentswf/testing.

At a glance ​

The workflow file

CallReturns
defineWorkflow({ meta, run, prepare?, id?, present?, report? })the workflow, as the file's default export
defineWorkflow({ …, file, events, starts? })a workflow whose runs take events

Agents

CallReturns
wf.agents.open({ key, runtime, instructions?, skills?, sandbox?, cwd?, pane?, tools?, timeout? })an agent
agent.run({ prompt, schema?, timeout?, label?, nudge? }){ outcome }, in the agent's same session
agent.compact({ prompt, timeout? })an outcome; the harness's own compaction
agent.set({ model?, effort?, timeout? })an outcome; later turns run at these
agent.fork({ key, runtime?, instructions?, pane?, tools?, timeout? })a new agent on a copy of this one's session
agent.executionthe agent's harness, model, effort and placement now
agent.close(reason?)true once closed; false if it was already closed
wf.agents.caller({ key })the session awf run --here was typed in, or null
wf.agents.forkCaller({ key, runtime?, instructions?, pane?, timeout? })a new agent on a copy of the session awf run was started from, or null
tool({ description, input?, handler, timeout? })a tool, for open's and fork's tools

Composing work

CallReturns
wf.parallel(items, (item, i) => …, { concurrency?, label?, timeout? })results in item order
wf.sandboxes.open({ key, cwd?, read?, write?, network?, sockets?, webSearch?, srt? or docker? })a sandbox, for open's sandbox
wf.decisions.decide({ key, model, state, questions, timeout? }){ answers, record }
choice(…), yesNo(…), score(…)a decision question
wf.messages.allow({ from, to, purpose, maxMessages?, timeout? })routes granted; also { between: [a, b], … }
wf.participants.connect({ key, labels? })a participant outside the run; .post(text), .release(reason?)

Stages and time

CallReturns
wf.stage(name, { result, summary? }?, async () => …)the stage's value; reused on a continue
wf.stop(reason)never; ends the attempt stopped
wf.sleep(name, "14d" or date)once the time recorded under name has come
wf.now()the run's time; a test's virtual clock in a test

Waiting, in every workflow; one with events parks while it waits

CallReturns
wf.receive({ label? })the events not yet acknowledged
wf.race(name, { branch: source or "2d", … }, { label? }){ key, [key]: what won }, recorded under name
wf.select({ branch: source, … }, { label? })each event a branch takes, one at a time
wf.timer(name, "2d" or date)a timer, a source; timer.cancel()
wf.signal(name)one declared event, as a source
wf.at(() => date or null)a select's branch, due at a time the state holds
wf.after("3h")now() plus a duration, as a Date
wf.startedthe event that started the run
agent.is(e), timer.is(e), signal.is(e)narrows an event

A workflow with events

CallReturns
agent.start({ prompt, schema?, meta?, … })a turn not awaited; its end arrives as {key}:ended, declared
agent.turn, turn.cancel(reason?)the started turn, until a wait takes its end
wf.agents.open({ …, keepAlive: { window?, onTimeout? } })the run stays live after the agent's turns
events: { "{key}:reply": ParticipantReply }a participant whose messages come to the code
wf.emit(target, run, name, data, { eventId? })"accepted", "duplicate" or "refused"; any workflow emits

The run

CallReturns
wf.log(message, fields?)nothing; a line in the progress view and awf logs
wf.usage()this scope's finished operations
wf.allowance(harness)what is left of a harness's plan
wf.runId, wf.attempt, wf.cwd, wf.deadlinethe run's id, the attempt's number, its directory and deadline, a Date

Results and errors

NameWhat it is
Outcomeshow a turn ended: answered, unanswered, blocked, timed-out, failed or cancelled
Deadlinestimeout, a duration or a Date, never past the bound around it
duration(text)a string read from data, checked, as a Duration
DeadlineExceededErrorwhat parallel, decide and a call past its scope's deadline reject with
DecisionErrora decision that was sent and didn't answer
Endinghow an attempt ended, parked included, as present and report get it
typeda TypeBox schema carrying its type, from agentswf/typebox
Typesthe types and constants agentswf/workflow exports

A workflow file ​

ts
// summarize.ts
import { typed } from "agentswf/typebox";
import { defineWorkflow } from "agentswf/workflow";
import Type from "typebox";

export const SUMMARY = typed(Type.Object({ summary: Type.String() }, { additionalProperties: false }));

export default defineWorkflow({
  meta: { name: "summarize", description: "Summarize one file." },
  async run(wf, args: { file: string }) {
    const agent = await wf.agents.open({ key: "reader", runtime: "claude" });
    const { outcome } = await agent.run({
      prompt: `Read ${args.file} and summarize it in two sentences.`,
      schema: SUMMARY,
    });
    return outcome.answered ? outcome.value : { summary: `no answer: ${outcome.reason}` };
  },
  // Turns the command line into args: `awf run summarize.ts -- src/index.ts`.
  prepare: ({ argv }) => ({ file: argv[0] ?? "README.md" }),
  // Optional: what a person sees at the terminal instead of the record.
  present: (result) => result.summary,
  // Optional: Markdown saved beside the run as report.md.
  report: (result) => (result ? `# Summary\n\n${result.summary}\n` : undefined),
});

The guide: Workflows.

defineWorkflow ​

defineWorkflow(spec) returns the workflow; the file exports it as its default.

Field
meta{ name, description, version? }. name names the folder its runs are kept in: letters, digits, ., _, @ and -, starting with a letter or digit. version, optional, is semver; stage says what it guards.
run(wf, args)The workflow. args and the result are JSON.
prepare({ argv, cwd })Turns the command line (argv after --, and cwd) into args. Required when run takes arguments: a workflow whose args aren't null without prepare fails tsc. Left out, the workflow takes none: its args are null, and any argv is refused. A throw ends the run before any agent opens. A run an event starts has no argv, and gets what prepare makes of none.
id(args)Optional. The run's id, such as a ticket's key: letters (either case), digits, ., _, @ and -, starting with a letter or digit, at most 128 characters; an id differing from another only in case is refused. awf run --id overrides it. With neither, awf makes one, except for a workflow with starts, whose run is then refused: its senders name its runs. A run's ref is {workflow}/{id}.
present(value, ending)Optional. What a completed attempt prints. Without it, or when it returns undefined or throws, awf run prints the whole record, out.json; --json always prints the record.
report(value, ending)Optional. Markdown saved as report.md beside the run. Called however the attempt ended, so a stopped run can still hand off what its stages found: value is undefined unless it completed. undefined writes no report.
file, events, startsA workflow whose runs take events: see Events.

ending says how the attempt ended. Every kind has stages, one per stage the attempt entered: stage, source ("ran" or "reused"), outcome (succeeded, stopped or failed), attempt (the one that ran it), spanMs, summary? and value?. Beyond that:

kindFields
completedvalue.
parkedlabel?, its last wait's label, and until?, an ISO time: a workflow with events waiting with nothing to do.
stopped, failed, timed-out, cancelledstage?, the stage it ended in; reason; continue, the command that goes on; needs?, the stages whose values --values must give to start part-way, each { stage, schema }, when it stopped for them.

The result, every agent's usage and how the attempt ended are kept in out.json, in .awf/runs/{workflow}/{id} under the working directory, for failed and cancelled runs too. The record says what else a run keeps.

Agents ​

The guide: Agents, Harnesses.

wf.agents.open ​

ts
const reviewer = await wf.agents.open({
  key: "review-security",
  runtime: { harness: "claude", model: "claude-sonnet-5-5" }, // placement defaults to "tui"
  instructions: "You review code for security problems. Report; don't fix.",
});
Field
keyRequired. The agent's name in this run, such as review-security: lowercase letters, digits, ., _, @ and -, starting with a letter or digit, as an event's or a stage's name is, since it names the agent's operations. Opening the same key again returns the same agent when every field given matches; a field that conflicts rejects. operator, awf, system and user are reserved, and so is a key a decision in the run already has. A key with a colon is refused: a colon is kept for awf's own event names, {key}:ended and {key}:reply.
runtimeRequired. An alias, "claude" or "codex"; { harness, model, effort?, placement?, metered? }; or { alias, harness?, model?, effort?, placement?, metered? } to constrain an alias, where harness and model must match it and effort replaces its effort.
instructionsStanding instructions, given once when the agent opens.
skillsExactly the skills this agent gets: { path }, an absolute path or file: URL of a directory holding a SKILL.md, or { repo: "owner/repo", skill, ref? }. Left out, it has what it would have without awf: yours on the host, none in a sandbox.
sandboxA sandbox from wf.sandboxes.open, or an inline spec for a private one. Left out, the agent is unsandboxed.
cwdWhere the agent works; wf.cwd by default, which awf run --cwd sets.
paneWhere its pane goes among the run's, and whether it is the one to look at: Where panes go.
toolsWhat it can ask your code to do mid-turn: tool.
timeoutBounds the open, as for run; the enclosing scope's by default.

The runtime fields:

Field
harness"claude", "codex", "pi" or "cursor".
modelThe harness's own model name.
effortIn the harness's own words: claude's low to max, codex's low to ultra, pi's off to max. cursor takes none: name the variant as the model (gpt-5.6-luna-high). Left out, the harness uses its default.
placement"tui", the default: the harness's own TUI, kept between turns, in a terminal pane in Herdr you can watch and type into; or "headless", a process per turn.
meteredtrue: required for headless claude. Subscription and metered says why.

The aliases awf run installs are claude (claude, sonnet) and codex (codex, gpt-5.6-sol). Both open in their TUI unless placement says otherwise.

agent.run ​

ts
const { outcome } = await reviewer.run({
  prompt: "Review the diff on this branch against main.",
  schema: FINDINGS,
  timeout: "15m",
  label: "first pass",
});
Field
promptRequired. The turn's prompt.
schemaA JSON Schema, such as a TypeBox one, the answer must fit. The agent answers with wfa result; a refused answer can be sent again within the turn. Without it, the answer is a string.
timeoutBounds the whole turn, nudges included: a duration from the call, a Date to end by, or false for no bound of its own. Never past the enclosing deadline.
labelThe turn's name in the progress view and the record.
nudgeOn by default: an agent that stops without an answer is nudged to give it. false turns it off. { prompt } replaces the nudge's opening line; the lines on how to answer still follow.

A schema wrapped in typed gives the answer its TypeScript type; a bare TypeBox schema doesn't carry one on its own. A wait on a participant's answer moves the turn's bound out by the wait's length, within the deadlines around it.

Every run on the same agent continues its session, so it remembers its earlier turns. Loops builds on that. How a nudge reads and when it comes: Timeouts and nudges.

Outcomes ​

run settles every ending on the agent's side as an outcome. It rejects only on misuse or a scope that has ended:

  • the enclosing scope's deadline has passed, or the scope was cancelled (DeadlineExceededError);
  • the agent is closed (logical agent is closed): stopped, or after a turn of it failed or timed out;
  • the agent has a started turn no wait has taken the end of;
  • it is called from a tool's handler the agent is in;
  • a continue is stopping for stage values it found missing. What each ending means says what each leaves of the agent.
kindFields
answeredvalue, the checked answer.
unansweredreason. The agent stopped without an accepted answer, even after a nudge.
blockedreason. The agent is showing a question only a person can answer, such as a permission prompt.
timed-outreason. The turn's deadline passed.
failedreason, retryable and login?. retryable is false for every turn that runs today; only a started turn whose attempt ended before it did is true. login is set when the harness has no login, or it was refused: { harness, provider?, run }, with run saying what the operator should run.
cancelledreason. The run, an enclosing scope or agent.close() stopped it.

Every outcome also has answered, true only for answered, and usage, the operation's record: its times, sessions and settings. Checking answered narrows outcome.value to the schema's type, and every other outcome to one with a reason:

ts
if (!outcome.answered) return { error: `${outcome.kind}: ${outcome.reason}` };
outcome.value.findings; // typed

A turn that fails or times out closes its agent, which takes no more turns; the calling session is the exception. A missing login fails every agent on that harness the same way, so a workflow that shouldn't go on stops on it:

ts
if (outcome.kind === "failed" && outcome.login) wf.stop(outcome.reason);

agent.compact ​

Runs the harness's own compaction, as /compact with a focus does. prompt says what to keep and what to drop; the next run goes on in the compacted session.

ts
const compacted = await builder.compact({
  prompt: "Keep the plan's decisions, the branch and the worktree. Drop file contents.",
});
if (!compacted.answered) wf.log("compaction didn't finish", { reason: compacted.reason });
  • Fields: prompt, required; timeout, as for run.
  • answered means it compacted. value is the summary from claude and pi, "" from codex, which keeps it encrypted, and "" from cursor.
  • Any other outcome leaves the context as it was, so going on is usually right.
  • Where: claude, codex and pi, in their TUI or headless; cursor in its TUI only. pi only summarizes what is older than its last 20k tokens, so a short pi session fails "nothing to compact". The calling session's ref, a CallerRef, has none.
  • It runs after the agent's earlier turns.

agent.set ​

Switches the model, the effort or both for every later operation, as /model or /effort does. The session and its context stay.

ts
const critic = await wf.agents.open({
  key: "critic",
  runtime: { alias: "codex", effort: "high" },
});
await critic.run({ prompt: "Review the change.", schema: FINDINGS }); // high
await critic.set({ effort: "low" });
await critic.run({ prompt: "Summarise." }); // low, and every turn after
  • Fields: model, another model of the same harness; effort; timeout, as for run.
  • answered means the switch is in force, and agent.execution shows the new settings.
  • It is refused for an effort the harness doesn't list and on a cursor TUI agent. The calling session's ref has no set. Another harness or placement is another agent.
  • A TUI agent whose switch fails or times out is closed, as after a failed turn.
  • Switching the model costs the cache: the next turn reads the whole context at full price.

agent.fork ​

Opens a new agent on a copy of this agent's session, so it knows what this agent knew without being told again, and reads that context from the provider's cache (the design record).

ts
const worker = await wf.agents.open({ key: "worker", runtime: "claude" });
await worker.run({ prompt: "Read the ticket and plan the change.", schema: PLAN });
const [security, tests] = await Promise.all([
  worker.fork({ key: "security", instructions: "Review the plan for security." }),
  worker.fork({ key: "tests", runtime: { placement: "headless", metered: true } }),
]);
  • The copy is taken when fork is called, after the agent's earlier operations. From then on neither sees the other's turns.
  • A fork has its parent's harness, model, effort, placement, working directory, sandbox, skills and tools. It may name its own instructions, tools ({} for none) and runtime, { placement?, metered?, effort? }, as open's fields: never a harness or model, since it continues its parent's session on them. It never inherits pane: without one it gets a tab of its own. timeout, as for run, bounds taking the copy and opening the fork.
  • It rejects before the parent's first turn, once the parent is closed, and after a set queued before it that didn't take. Every harness forks, in its TUI or headless, sandboxed too.
  • The same key with the same parent and spec returns the same agent.

agent.execution ​

The agent's settings now: harness, model, effort?, placement?, metered?, alias? when it was opened through one, and caller: true for the calling session, whose model is "". As it was opened, then as the last answered set left them.

Where panes go ​

Each TUI agent gets a tab of its own in the run's Herdr workspace, closed once it is done. pane places it elsewhere as it opens, or at its first turn in a workflow with events (Where panes go):

ts
const lead = await wf.agents.open({
  key: "lead",
  runtime: "claude",
  pane: { tab: "review", front: true }, // a tab of its own, brought to the front
});
await lead.run({ prompt: "Read the change and plan the review." });
const lensA = await lead.fork({ key: "lens-a", pane: { beside: "lead", side: "right" } });
const lensB = await lead.fork({ key: "lens-b", pane: { beside: "lens-a", side: "below" } });
  • A new tab: { tab?, front? }. tab is its label, the key by default; every tab is a new tab.
  • Beside another agent: { beside: key, side: "right" | "below", share?, front? } splits that agent's pane; share, 0.2 to 0.8, is the new pane's part, 0.5 by default.
  • front: true marks the pane to look at: as it is placed in your own workspace (awf run --workspace origin), its tab becomes the active one there. Anywhere else it does nothing: awf never moves your view to another workspace. Where several are in front, the last placed wins.
  • Which workspace, and whether panes stay, are yours, not the workflow's: awf run --workspace and --keep-panes, which the run keeps for every attempt. A beside pane goes where its target is; one whose target has no pane gets a tab of its own in that workspace. A run an event started, with awf emit, has neither flag and takes the defaults.
  • Refused at open: pane on a headless or sandboxed agent, and a malformed value (an unknown field, a side other than right or below, a share out of range, an agent beside itself).
  • A well-formed pane that can't be placed, such as beside an agent with no pane, falls back to a tab of its own and never fails the agent. The run's closing lines say which agent fell back and why.

agent.close ​

ts
await triager.close(); // true once closed

agent.close(reason?) closes an agent the workflow is done with, so it doesn't sit idle until the run ends. A turn it is running ends cancelled, its pane is closed or, as --keep-panes says, kept, and its key can't be opened again. It resolves true once the agent is closed, a turn that failed and closed it included, and to a close() made while another is closing it; false if an earlier close() had already closed it. Closing the calling session hands it back to its operator then rather than at the end of the run.

wf.agents.caller ​

awf run --here, typed in a claude, codex, pi or cursor session in a Herdr pane, starts the run in a tab of its own, and the run can drive that session as an agent (the design record). The guide: Calling session.

ts
const author = await wf.agents.caller({ key: "author" });
if (!author) throw new Error("start this workflow with awf run --here from an agent");
const picked = await author.run({ prompt: "Pick a number and remember it.", schema: PICKED });
  • null means there is no calling session: refuse, or open an agent of your own.
  • The same key returns the same agent; another key rejects.
  • It is the operator's session, so its ref is a CallerRef: an agent without compact or set. Called anyway, from JavaScript, set is refused and compact ends failed with the session as it was. It takes no instructions, and stays usable after a turn that fails, is cancelled or times out. The operator interrupting a turn settles it cancelled. Where the harness's interrupt can't be recognised, an unanswered turn isn't nudged by default. Its forks are forkCaller's, and take no tools.
  • When the run ends, the session gets one message saying how and where the run's records are. A run that is killed sends none.

wf.agents.forkCaller ​

Opens an agent that knows what the session awf run was started from knows, without spending that session's context on the work. The session waits on awf run as an ordinary command and reads the result as its output. The guide: Calling session.

ts
const fixer =
  (await wf.agents.forkCaller({ key: "fixer", pane: { front: true } })) ??
  (await wf.agents.open({ key: "fixer", runtime: "claude" }));
  • Fields: those of fork but tools: key, runtime, instructions, pane and timeout. Root scope only.
  • null means there is no session to fork, and the run's output says why.
  • The copy holds the session up to the command that started the run; with --here, after the run's turns to it. The fork runs on the model the session last ran, with awf's launch flags and the operator's skills, not their settings or MCP servers.
  • Under codex's default sandbox, the run is inside it and TUI agents can't reach Herdr. The awf-run skill says how each harness waits on a long run.

tool ​

A tool is a function in your workflow an agent calls while it works, with wfa tool, and reads what it returns. Holding a tool is what lets the agent call it. The guide: Tools.

ts
const CloseItem = typed(
  Type.Object({
    id: Type.String(),
    status: Type.Union([Type.Literal("done"), Type.Literal("wontfix")]),
    why: Type.String(),
  }),
);

const triager = await wf.agents.open({
  key: "triager",
  runtime: "codex",
  tools: {
    close_item: tool({
      description: "Close an open item as done or won't fix, saying why.",
      input: CloseItem,
      handler: ({ id, status, why }) => {
        backlog.close(id, status, why);
        return `Closed ${id}.`;
      },
    }),
    list_open: tool({
      description: "The items still open, one per line.",
      handler: () => backlog.open(),
    }),
  },
});
Field
descriptionRequired. What it is for, as the agent reads it in its prompt.
inputAn object schema. Wrapped in typed, it types the handler's input. Left out, the tool takes none, and the handler gets {}.
handler(input, call)Required. Runs once per accepted call; what it returns is what the agent reads. call has id, agent (the caller's key, a fork's own) and signal, aborted when the call's budget passes or its turn ends.
timeoutEach call's budget, 30 s by default; false leaves only the turn's deadline.
  • The agent's prompt teaches its tools, in every turn: each one's name and description, and how to call it. A simple input, up to 6 strings, numbers, booleans or enums, is taught as flags, wfa tool close_item --id <text> --status done|wontfix --why <text>; any other as JSON.
  • The result: a string prints as is, other JSON as JSON, nothing as nothing; a throw fails the call, not the run. Input that doesn't fit the schema is refused before the handler runs, and the agent corrects it.
  • Keep the handler short: it runs while the agent waits. A shell command that runs long is backgrounded by claude after 2 minutes and cursor after 30 s, and handed back to codex to poll after 10 s. An agent that calls again with the same input while the call runs gets its answer, once; a call after it ended runs it again, so a handler past 10 s should be safe to repeat.
  • It can't give work to the agent that called it, which is in a turn: run, start, compact, set, fork, close and cancel() on its started turn throw, as they do on an agent whose handler ran this one's, and so does wf.receive(). It may run another agent, except in the message turns a stage runs after its body, where no code can.
  • Limits: names match ^[a-z][a-z0-9_]{0,47}$; at most 16 tools an agent, 1 KiB a description, 8 KiB a schema, and 64 KiB for a call's input and for its result.
  • On a reopen, tools match by name, description and input schema, never by handler: matching ones take the new handlers and budgets. A fork has its parent's unless it gives its own.
  • Recorded: each call, its input and its result or error are in the run's event log, and awf logs shows them.

Composing work ​

wf.parallel ​

Promise.all with a concurrency limit and a deadline, which the engine tracks and shows. Results come back in item order. The guide: Parallel.

ts
const LENSES = ["security", "correctness", "tests"] as const;

const reviews = await wf.parallel(
  LENSES,
  async (lens) => {
    const agent = await wf.agents.open({ key: `review-${lens}`, runtime: "codex" });
    const { outcome } = await agent.run({
      prompt: `Review for ${lens}.`,
      schema: FINDINGS,
    });
    return outcome.answered ? outcome.value.findings : [];
  },
  { label: "review", concurrency: 2 },
);
  • concurrency, a positive integer, defaults to all at once. label names the group in the progress view ("review 2/3"). timeout bounds the whole group, as for run.
  • When its deadline passes, the turns still running end timed-out, and parallel rejects with DeadlineExceededError: the results already gathered are dropped.
  • It's fail-fast. If one item throws, the others are cancelled and parallel rejects. Outcomes don't throw, so in the pattern above one bad agent never stops the rest.
  • It nests. An empty list resolves to [].

Deadlines ​

A run has no deadline unless awf run --timeout 2h gives it one, as wf.deadline. Everything inside inherits it. A call that takes timeout sets its own bound: a duration, "10m", counts from the call; a Date is the time it ends by, but not on a tool or a route, whose bound is per call; false means no bound of its own. Whatever it gives, the bound never reaches past the stage, parallel or run around the call: a later one is cut to it, so agent.run({ prompt, timeout: "2h" }) inside a parallel with timeout: "20m" has twenty minutes at most. The guide: Timeouts and nudges.

When a deadline passes, turns under it end timed-out, and parallel and decide reject with DeadlineExceededError, which agentswf/workflow exports. Without any deadline, turn liveness still stops a turn that stalls. An agent waiting on a participant's answer isn't working: its clock stops while it waits, within any deadline around it. When the run ends, for any reason, every agent it opened is closed and every sandbox torn down; only kept panes and the calling session stay.

wf.sandboxes.open ​

The guide: Sandboxes.

ts
const box = await wf.sandboxes.open({
  key: "work",
  write: ["."], // the working directory is writable; everything else is read-only
  network: ["registry.npmjs.org"], // beyond each harness's own model API
  srt: {}, // the provider, required: srt, or docker: { image? }
});

const coder = await wf.agents.open({ key: "code", runtime: "claude", sandbox: box });
const tester = await wf.agents.open({ key: "test", runtime: "codex", sandbox: box });
const reader = await wf.agents.open({
  key: "read",
  runtime: "claude",
  sandbox: { read: ["/data"], srt: {} }, // an inline spec: a private sandbox for this agent only
});
Field
keyRequired. The sandbox's name in this run.
cwdIts working directory, wf.cwd by default; always readable inside.
readHost paths visible read-only, at the same path, beyond the working directory.
writeHost paths visible and writable. "." makes the working directory writable.
networkDomains reachable beyond each harness's model API: exact, or *.example.com for its subdomains only. No scheme, port, path or IP address.
socketsHost unix sockets the agents may connect to: a socket, or a directory of them, where they may create their own if it is also in write. srt only; docker refuses it.
webSearchtrue keeps the harnesses' model-side web tools, which are off inside otherwise.
srt or dockerRequired, exactly one: srt: {}, Anthropic's sandbox-runtime on your machine; docker: { image? }, a container running as your user. There is no default.

An inline spec on open takes the same fields but key and cwd, and is always private.

  • Web search runs on the model provider's servers, past any network rule, so with webSearch: true it can carry out what the agents read. claude's WebFetch goes with it. cursor keeps its web tools either way, and pi has none.
  • Agents sharing a sandbox can read each other's files, their homes and skills included. Under docker they share one container; under srt each agent is wrapped on its own, and they meet only in the writable paths.
  • Logins: claude needs CLAUDE_CODE_OAUTH_TOKEN and cursor CURSOR_API_KEY in your environment, as their logins live in the keychain; codex and pi copy theirs in.
  • git works. A writable worktree can commit, but its hooks and config can't be changed from inside.
  • awf run --sandbox box.json puts every agent of the run in one sandbox instead; a workflow that opens its own is then refused.
  • A sandbox closes when the run does, after every agent in it.

wf.decisions.decide ​

Asks a decision model closed questions about a piece of state and returns a probability for every possible answer, in about 200 ms, with no agent involved. The state is billed once per call, so ask all your questions together. The guide: Decisions.

ts
const { answers } = await wf.decisions.decide({
  key: "triage-42",
  model: "jev",
  state: { title, body },
  questions: {
    team: choice("Which team owns this?", { payments: null, accounts: "logins, profiles" }),
    bug: yesNo("Is this a bug report rather than a feature request?"),
    urgency: score("How urgent is it?", ["whenever", "this sprint", "today"]),
  },
});

answers.team.choice; // "payments" | "accounts", typed from the options
answers.team.probabilities; // { payments: 0.97, accounts: 0.03 }
answers.bug.yes; // 0.91
answers.urgency.expected; // 1.4, the probability-weighted level
Field
keyRequired. Names the call in its record; not unique. A name without a colon, as an agent's key is, and never one an agent in the run has.
modelRequired. A decision model alias: jev is Jev through OpenRouter, which needs OPENROUTER_API_KEY in the environment or a .env where you run awf.
stateRequired. What every question is about: a string, a JSON object or an array.
questionsRequired, at least one. Each is answered on its own.
timeoutAs for run; the scope's by default.

The questions. instructions, an option's description and a level are text, or a JSON object of structured text:

  • choice(instructions, options): options maps each name to a description, or null. Its answer has choice, the most likely (the first on a tie), and probabilities by name.
  • yesNo(instructions, { yes, no }?): its answer's yes is the probability of yes.
  • score(instructions, levels): levels lowest first. Its answer has level, the most likely index (the lowest on a tie), expected, the probability-weighted level, and probabilities, one per level.

decide resolves to { answers, record }. A decision that was sent and didn't answer rejects with DecisionError (its record attached) or DeadlineExceededError; a spec the engine refuses, such as an unknown alias, rejects with an Error before anything is sent. Set your own threshold on the probabilities: the findings show Jev is reliable at matching text to options, and not at anything that needs reading code.

wf.messages.allow ​

Grants a route on which one agent may message another, or a participant, while both work. Your code grants the route and reads typed results; what the agents say is between them. The guide: Messaging.

ts
await wf.messages.allow({
  between: [tester, coder],
  purpose: "Revise the implementation until the tester approves it.",
  maxMessages: 6,
});
const verdict = await tester.run({
  prompt: "Test the change; ask the implementer for fixes.",
  schema: VERDICT,
});
Field
from, toOne way: from may message to, which answers when asked.
between: [a, b]Both ways, sharing one count. Give this or from and to.
purposeRequired. Shown with every message on the route.
maxMessagesLowers the ceiling of 40 messages a route an attempt, replies included.
timeoutA duration: a question on the route, to an agent or a participant, closes unanswered after this long. Without it, a question waits until answered; an agent's --timeout can only shorten it.
  • Scope: granted inside a stage, a route closes with the stage; otherwise with the attempt. A pair has one open route at a time; granting it again must match, or it rejects.
  • Agents use wfa, which their prompt teaches them: wfa peers, wfa send {peer}, wfa send --await-reply {peer} to ask, and wfa reply {peer}. The answer to a question starts the asker's next turn.
  • A message arrives between turns, never into a running one. An idle agent gets a turn of its own for it; a busy one gets it with its next turn. An agent that is asked a question can't give its result until it replies.
  • --await-reply works on headless claude, codex and pi, and on a claude TUI agent that confirms what it is sent. Elsewhere it is refused unsupported, a codex TUI agent included, and the agent sends without waiting.
  • Recorded: wf.usage() marks each operation a message started, and awf run, awf logs and awf runs show the messages.

claude's own messaging between sessions is a different thing, on by default: design/messaging.md says what it reaches.

wf.participants.connect ​

Connects a participant outside the run, such as a coordinator, that agents report to and ask. Who answers for it is chosen when the run starts, not here: awf run's terminal, another claude or codex session, or an app. Answering for a participant is the other side.

ts
const coordinator = await wf.participants.connect({
  key: "coordinator",
  labels: { audience: "operator" },
});
await wf.messages.allow({
  between: [reader, coordinator],
  purpose: "report findings that hold up; ask when the logs can't settle it",
  timeout: "2h",
});
  • key, required, may not be reserved (operator, awf, system, user) or held by any agent or participant in the run.
  • labels is any JSON object for whatever answers; awf passes it along in awf inbox and never reads it.
  • Agents talk to it as to any peer: wfa send coordinator reports, and wfa send --await-reply coordinator asks; the answer starts the agent's next turn, in the same session, hours later if need be. The guide: People.
  • It stays connected past the workflow's return while a question to it is open, so the run waits for the answer. coordinator.release(reason?) ends it sooner, closing its routes and its open questions; it resolves false when already released.
  • Run in a terminal, awf run asks each question there. Otherwise the answer comes from awf reply, by a session or an app reading awf inbox.
  • participant.post(text) writes to it without a turn: an item in awf inbox.
  • Its messages go to agents. They reach the workflow's code only when it declares {key}:reply: a participant's messages to the code.

Stages and time ​

wf.stage ​

A run is one piece of work, kept in .awf/runs/{workflow}/{id}, and each awf run of it is an attempt. A stage is a named step whose result the run keeps, so awf run file.ts --id {id} --continue reuses what succeeded and picks up at the stage that didn't. The guide: Runs, Durability.

ts
const doc = await wf.stage("doc-review", { result: DOC, summary: (d) => d.path }, async () => {
  const { outcome } = await reader.run({
    prompt: `Find the ticket doc for ${ticket}`,
    schema: DOC,
  });
  if (!outcome.answered) return wf.stop(outcome.reason); // inside: a continue redoes doc-review
  return outcome.value;
});
await wf.stage("notify", async () => {
  // no result: it only has to happen
  await postToSlack(`${ticket}: reviewed`);
});
  • A stage's only output is its return value. With result, a schema, it must fit it, when recorded and whenever a continue reuses it; without, the stage returns nothing. summary(value) is its line in the progress view and the record.
  • A reused stage isn't run. No agent is asked anything, and a variable assigned inside it stays unset.
  • Code between stages runs on every attempt, so keep it safe to repeat. Push, post, compact or create inside a stage.
  • One stage at a time, each name once per attempt. Names are lowercase letters, digits, ., _, @ and -, starting with a letter or digit, at most 128 characters. Parallel work and loops go inside a stage. A workflow with events names a stage by its one effect instead, and enters it as often as it likes (stages by content).
  • meta.version guards reuse: a record from another major version (another minor under 0.x), or one whose value no longer fits its schema, stops the continue at that stage, and --from-stage {stage} redoes from there.

The full model is in design/runs-and-stages.md.

wf.stop ​

wf.stop(reason) ends the attempt stopped rather than failed. Inside a stage, a continue redoes that stage; between stages, a continue checks again. Don't catch it: caught, it fails the stage that caught it. Write return wf.stop(reason) where TypeScript should know the code after it isn't reached. Stopping with a reason says when to use it; Runs has the exit codes.

wf.sleep ​

wf.sleep(name, when) waits until when and goes on. when is a duration from the first call under name, or a Date. The time is recorded under name the first time, so a rerun waits for what is left of it, and returns at once once it has passed. A sleep in a loop is named per round. In a workflow with events, it is a race of one timer of its own, {name}@sleep, and the run parks while it waits; without events, it waits in its process. It throws in a stage or a tool's handler, and sleeps share their names with races.

wf.now ​

wf.now() is the run's time. Workflow code reads it, not Date.now(), so a test's virtual clock is the one it reads.

Events ​

A workflow with events takes events its senders address to a run by id, waits for them, and parks between them. The guide teaches the model: Events; running the senders and awf tick: Hosting events. This part specifies the calls.

ts
export const ddCase = defineWorkflow({
  meta: { name: "dd-case", description: "Investigate one Datadog case." },
  file: import.meta.path, // where senders find it, and its runs live
  events: {
    "dd.changes": CHANGES, // a schema per name
    "dd.closed": CLOSED,
    "investigator:ended": AgentEnded, // awf's: the end of a turn start() began
  },
  starts: ["dd.changes"], // the events that may start one; absent, any
  async run(wf) {
    const kase = openCase(wf.runId); // state lives in files, not variables
    const investigator = await wf.agents.open({ key: "investigator", runtime: "claude" });
    for (;;) {
      const events = await wf.receive({ label: `${kase.unseen()} unseen` });
      for (const e of events) {
        if (investigator.is<string[]>(e) && e.data.outcome.answered) kase.markGiven(e.data.meta);
        if (e.name === "dd.closed") await wf.stage(`close-${e.id}`, () => kase.close(e.data));
      }
      if (!investigator.turn && kase.needsLook()) {
        const given = kase.pending();
        await investigator.start({ prompt: promptFor(given), meta: given }); // returns at once
      }
    }
  },
});
sh
awf emit --target case.ts --id checkout-timeouts dd.changes '{"case":"checkout-timeouts"}' --event-id 83d34d7

The fields defineWorkflow adds:

Field
eventsRequired. Everything that can wake a run: each event's name and the schema of its data. The workflow's own names have no colon. awf's take its schemas: "{key}:reply": ParticipantReply, a participant's messages to the code, and "{key}:ended": AgentEnded, the end of a turn start() began, the key without a colon. Any other name with a colon, or another schema for one of awf's, fails tsc, and is refused when the workflow loads.
startsThe workflow's own events that may start a run with an id that has none; any other is refused then. Absent, any of its own starts one; an empty list is refused when the workflow loads.
fileimport.meta.path, required with starts: where a sender holding the workflow, as wf.emit does, finds it, and so which project its runs live in, the nearest directory above it holding .awf/. Without it, awf run and awf emit --target find it by the path they are given, and wf.emit to it throws.
  • Ids. The sender names the run: awf emit --id, or wf.emit's second argument; wf.runId is the id and the ref is {workflow}/{id}. Without id(args) or --id, awf run refuses a run of a workflow with starts, and makes an id for one without, as for a plain run. Event ids and stage names are lowercase letters, digits, ., _, @ and -, at most 128 characters, so a stage named by an event id (close-{id}) is always valid. Ids starting awf. are awf's own.
  • Starting by hand. awf run {file} --id {id} -- {args} makes the run with those args and starts it, detached in its project; it runs to its first wait, which returns nothing, and parks. It ignores starts, and a run already there is only woken.
  • Parking. A wait with nothing else of the run working (no turn, decision, sandbox or other stage) and nothing to take ends the attempt parked, and the process exits. Code running beside the wait, outside any awf call, is cut off. The next event the wait takes, or awf tick once its time is due, continues the run from the top.
  • Agents on a wake reopen on their kept sessions, without their instructions again, and a TUI agent's pane opens at its first turn. Before that turn, in an attempt, it can't be forked or compacted, and an agent beside it gets a tab of its own. An agent with skills or a sandbox, whose home is made anew, starts a new session, as one whose harness changed does.
  • Stages by content. A succeeded stage is reused wherever and however often its name is entered, in any attempt and any version of the code, while its value fits. A wait goes outside a stage: wf.race is recorded under its own name.
  • --from-stage {stage} on a continue redoes that stage and every one after it, as in a plain run, each as the code reaches it. The stages before it are reused by content. After it, in this attempt and every later one, a stage first entered after it whose record an attempt before the redo wrote runs again, writing a new one; one never entered keeps its record. So an attempt that parks partway leaves the next wake, which carries no flag, to redo the rest. The events are untouched: a race keeps its recorded answer, but an event a receive or select took and acknowledged isn't taken again, so a stage that handled one isn't reached and keeps its record. An attempt that would park, or return, before reaching the stage stops instead, never reached. It takes no --values, and a new run takes no --from-stage.
  • return ends the run for good: later events for it are refused.
  • Without events a workflow waits as any does, with receive, race, select, timer and sleep, but never parks: it waits in its process. It declares no name, so wf.signal takes none, and agent.start and keepAlive throw, saying why.

wf.receive ​

wf.receive(options?) acknowledges what its last call returned, then waits for and returns the events not yet acknowledged, in arrival order. The first call takes the event that started the run, if one did. After a crash, the first call returns the unacknowledged batch again, alone. It throws in a tool's handler.

Option
labelWhat awf runs shows while the run waits here or is parked at this wait: a string, or a function asked when the wait begins.

Each event has:

Field
idIts id: the sender's eventId, or one awf made, starting awf..
nameA declared name, {key}:reply and {key}:ended included, or timer:due.
dataTyped by its schema; for awf's own, as agent.start, a participant's messages and wf.timer say.
fromAn agent's or participant's key, a timer's name, the sending run's ref for wf.emit, or operator for awf emit.
atWhen it was appended, an ISO time; for timer:due, when the timer was due.

agent.start ​

agent.start(spec) takes what run takes, plus meta, any JSON, and returns once the turn is launched, at the run's root scope only. Its end arrives at a wait as {key}:ended, which the workflow declares, "investigator:ended": AgentEnded, from the agent's key, with turn, the outcome, checked against schema as run's is, and meta (null when absent). start() for an agent whose end isn't declared throws, naming the declaration. A turn started before a park is started again by the wake's rerun.

agent.turn ​

agent.turn is the started turn until a wait takes its end, restored on a continue for an end no wait took, and null otherwise. Meanwhile the agent refuses start, run, compact, set, fork and messages from other agents. agent.turn.cancel(reason?) stops it, and resolves once its cancelled end is recorded for a wait to take, or false at once when it had ended already; a tool call it has running is aborted through call.signal, and the end waits for the handler, at most 5 s more. A wait takes its end by naming the agent, { done: agent }: an agent has at most one started turn.

is ​

agent.is<Meta, T>(e) is true for that agent's {key}:ended and narrows its meta and outcome. timer.is(e) is true for its timer:due; wf.signal(name).is(e) for an event of that name. Any other event is told by its name: e.name === "thread:reply" narrows as e.name === "dd.closed" does.

keepAlive ​

An agent opened with keepAlive keeps the run live, not parked, for a window after each turn it runs, awaited, started or a message's. Its pane and the run's process stay, so a message then is a warm turn in the same pane, and its end opens a new window.

ts
const investigator = await wf.agents.open({
  key: "investigator",
  runtime: "claude",
  keepAlive: {
    onTimeout: async (agent: AgentRef) => {
      // Written when the window passes, from the case as it is then, while the cache is warm.
      await agent.compact({ prompt: focusFor(kase.view()) });
    },
  },
});
  • window, a duration such as "90s", is how long the run stays live after the agent's last turn, an hour by default: the life of claude's prompt cache on a subscription.
  • onTimeout(agent) is called once the window passes, if a turn ended since it last ran, at a wait with nothing to take and nothing working, which awaits it in its own scope. A turn it runs opens no window, though a message it has a peer send is that peer's turn, and an answer the peer sends back after the handler's turn has ended opens this agent's window again. A throw is logged, and the run parks anyway.
  • The run parks once no window is open and no handler is due. A turn that fails or times out closes the agent, and a closed agent holds no window. Every agent's pane stays with the run: none is released alone.
  • In a workflow without events, whose runs never park, open refuses it. On a reopen it takes the new spec; left out, the agent keeps what it has.

The run's terminal shows "live until {time}" on the row of each idle agent whose window is open; awf runs shows the run live and waiting.

A participant's messages to the code ​

A workflow that declares "{key}:reply": ParticipantReply in events takes each message on participant key at a wait, as {key}:reply from its key, with text and author?, rather than having an agent take it. Connect the participant as any other, without to:

ts
// Its messages reach the code: "approver:reply" is declared in events.
const approvals = await wf.participants.connect({ key: "approver" });

awf emit {ref} {key}:reply '{"text":"approved","author":"dana"}' --event-id {message id} delivers one, and continues a parked run: the id is the message's own, so a resend is a duplicate. An undeclared {key}:reply, or data that doesn't fit ParticipantReply, is refused. It is checked against the definition alone: one sent before the run connects the participant waits for the run's next wait. Only a run that is there takes one; awf emit --target refuses it. A race or select takes them as wf.signal("{key}:reply"), the whole event. A participant whose reply isn't declared still answers the agents that ask it; its messages never reach the code. A parked run keeps it connected.

Waiting on time ​

A duration, the type Duration, is a decimal and s, m, h or d: "90s", "10m", "1.5h", "14d". Every call that takes a length of time takes one, a timeout as a timer. A unit typo fails tsc, and a number that isn't plain throws; a bound also refuses one of no length, "0s", which a timer takes as now. A string read from data is checked with duration(text), which returns it as a Duration and throws, saying the grammar, for anything else: const every = duration(event.data.every). A timer is a time recorded once under a name; due, a wait takes it as timer:due, from its name, at its due time, beside the run's other events. A source is something a wait can take: a timer, a declared event (wf.signal(name), wf.signal("thread:reply") for a participant's messages), or an agent (the end of the turn it started). Three waits take sources, beside receive:

ts
// A reminder every two days, an approval, or an expiry at fourteen.
const expiry = wf.timer("doc-expires", "14d");
for (let round = 1; ; round++) {
  const next = await wf.race(
    `approval-${round}`,
    {
      reply: wf.signal("approver:reply"), // a participant's message to the code
      remind: "2d", // a timer of the race's own
      expiry, // a timer the code made, shared by every round
    },
    { label: "awaiting approval" },
  );
  if (next.expiry) return "expired";
  if (next.reply && isApproval(next.reply.data.text)) break;
  if (next.remind) await wf.stage(`remind-${round}`, () => approvals.post(REMINDER));
}
ts
await wf.stage("release", () => release(wf.runId));
await wf.sleep("follow-up", "14d");
ts
const replies = wf.signal("approver:reply");
for await (const next of wf.select({ reply: replies, closed: wf.signal("dd.closed") })) {
  if (next.closed) return kase.close(next.closed.data).then(() => "closed");
  if (next.reply) await investigator.run({ prompt: next.reply.data.text });
}

What a branch hands back: a reply as its text and author, a turn's end as its outcome and meta, a timer's as its name, each with the event's id and at; a signal, the event.

wf.race ​

wf.race(name, branches, options?) waits for the first of its branches and answers { key, [key]: … }: if (next.reply) and switch (next.key) both narrow.

Argument
nameRequired. Where the answer is recorded. Shared with sleeps; a race's own timers are {name}@{branch}.
branchesRequired, at least one, none named key. Each is a source or a duration.
options.labelWhat awf runs shows while the run waits here: a string, or a function asked when the wait begins.
  • It takes only what it names: the rest stays for a later wait, and doesn't wake a run parked at the race.
  • Recorded under name, so a rerun gets the same answer back and walks on: a race in a loop is named per round, and a name answers once an attempt. Races and sleeps share their names.
  • Of two ready, the earlier at wins; of two branches taking one event, the first declared.
  • A branch given a duration is a timer of the race's own, {name}@{branch}, cancelled when another branch wins; a wf.timer() passed in is the code's to cancel. A branch takes a duration, not a Date, and not wf.at: a fixed time is wf.timer(name, date).
  • It throws in a stage or a tool's handler.

wf.select ​

wf.select(branches, options?) is an async iterable: it yields each event its branches take, one at a time, as { key, [key]: … }, until the loop returns or breaks.

Argument
branchesRequired, at least one, none named key. Each is a source or a wf.at.
options.labelA string, or a function asked at each wait: what awf runs shows.
An event is acknowledged
when the loop asks for the next; after a crash, the one taken and not acknowledged comes back once.
What no branch names stays for a later wait. A branch is a source, not a duration: a timer in a loop is wf.timer()'s. It throws in a stage or a tool's
handler.

wf.timer ​

wf.timer(name, when) returns its source at once.

Argument
nameRequired. Where the time is recorded; no @, which a race's own timers use.
whenRequired. A duration from the first call under the name, or a Date.

The timer has name, due (an ISO time), cancel() and is(e); its branch hands back name, id and at.

  • The first call names the time: a later one, in this attempt or any other, gets the recorded timer, and one asking another time is logged and ignored. So a timer per round is named per round, and a fixed time is a duration or a Date from the run's files: one built from now() differs on every rerun.
  • timer.cancel() stops it, and a timer:due of it not yet taken is withdrawn; the name stays cancelled.
  • In a receive loop, timer.is(e) tells its event, or e.name === "timer:due" with e.from, its name, for one an earlier attempt made.

wf.signal ​

wf.signal(name) is one of the workflow's declared events, as a source; its branch hands back the event, typed by its schema. A name events doesn't declare fails tsc, and throws. A participant's messages are wf.signal("{key}:reply"); an agent's end is not a signal: name the agent, { done: agent }.

wf.at ​

wf.at(read) is a select's branch due at a time the run's state holds: read returns a Date, or null for none, and is called at each wait, so the branch follows the state as the code moves the time. It answers { at }, the time it came for.

ts
for await (const next of wf.select({
  reply: replies,
  nudge: wf.at(() => ledger.nextNudge()), // Date | null, from the ledger's file
})) {
  if (next.nudge) await nudge(); // which puts ledger's nextNudge off, or clears it
  if (next.reply) ledger.answered(); // nextNudge = null: no more nudges
}
  • It isn't a timer: nothing is recorded under a name, there is nothing to cancel, and it lives as long as its loop. Events ready at the wait, timers' among them, come before it.
  • A time returned once isn't returned again by a branch of that name, so a handler that leaves the state alone doesn't fire on every pass. After a crash, a time returned and not acknowledged comes back once, if the state still says it.
  • read is cheap and synchronous, and reads state that outlives the process (files, or what is rebuilt from them): a parked run wakes in a new one and reads it again. wf.now() and wf.after() throw inside it, as does returning anything but a valid Date or null.
  • A race refuses it.

wf.after ​

wf.after(duration) is now() plus a duration: a time for the state to keep, where it is set, as wf.at reads it.

wf.started ​

wf.started is the event that started the run, typed by events; absent for a run started by hand.

wf.emit ​

wf.emit(target, run, name, data, { eventId? }) sends an event to the run run of the workflow target, imported from its file, so a wrong name or shape fails tsc; awf emit --target {file} checks the same at run time (Sending events with awf emit). Either returns accepted; duplicate, the run has an event of that id already; or refused, the run returned, or it has none and starts doesn't list the event. A name target doesn't declare, or data its schema refuses, throws. An id names one occurrence for good; without one, nothing deduplicates the event. A sender is refused when prepare refuses no argv. Any workflow can emit; the target is one with events, and a target without file throws: wf.emit finds a workflow by it.

A parked run's until in awf runs is the earliest timer, or time read from state, its wait takes, and awf tick continues it once that is due: awf tick on a schedule.

The run ​

wf.log ​

ts
wf.log("reviewed", { findings: 12 }); // a line in awf's live progress and in `awf logs`
wf.usage(); // this scope's finished operations: times and sessions

fields is a JSON object.

wf.usage ​

wf.usage() returns this scope's finished operations and its descendants', in the order they were dispatched: each one's agent, settings, times, sessions, stage and label, and for one a message started, cause. Costs are priced after the run, from each agent's tokens: each stage's line gains what its agents cost, and one summary closes the run. out.json keeps the tokens, each stage's share and the price table used.

wf.allowance ​

wf.allowance(harness) reads what is left of a harness's plan now, as awf allowance does, with no model turn, on the login the run's agents get. It returns one harness of awf allowance --json's report: read: "plan" with each window's usedPercent, its resetsAt and, for a window that limits only some models, which; or read: "none" with a reason, which means unknown, not room to spare. A harness awf doesn't know throws. The read isn't recorded: a continue reads again, and a stage keeps one.

ts
// examples/event-sketches/dd-tick.ts
// A window of some models' only, as `week (Fable)`, doesn't limit the triage's sonnet.
const plan = await wf.allowance("claude");
if (plan.read === "plan" && plan.windows.some((w) => !w.models && w.usedPercent >= 80))
  return { changed: 0, skipped: "claude's plan is over 80% used" };

wf.runId, wf.attempt, wf.cwd, wf.deadline ​

  • wf.runId is the run's id, the same in every attempt.
  • wf.attempt is which awf run of the run this is: 1, then one more for each continue.
  • wf.cwd is the working directory, which awf run --cwd sets.
  • wf.deadline is the run's deadline, a Date, absent unless awf run --timeout sets one. Read it with wf.deadline.getTime().

typed ​

ts
import { typed } from "agentswf/typebox";

export const VERDICT = typed(Type.Object({ approved: Type.Boolean() }, { additionalProperties: false }));

agentswf/typebox exports one function, typed. It returns the TypeBox schema it is given, unchanged, typed as an OutputSchema of the value the schema describes, so run, a tool's input, stage and events infer that value with no type argument. A schema of a value that isn't JSON, a Date or a bigint, is a type error where it is written.

Types ​

What agentswf/workflow exports for a workflow's own signatures, beside the calls above.

Name
WorkflowContext<Events>wf, typed by the workflow's events. Bare, WorkflowContext is any workflow's, for a helper that takes whichever it is passed.
AgentRefAn agent the run opened or forked: a CallerRef, and compact and set.
CallerRefWhat caller returns, and what every agent is: key, execution, run, fork (taking no tools), start, turn, is and close; a source, and a peer for allow. A helper for any agent, the calling session included, takes one.
EventSchemasWhat events is: each name's schema.
ParticipantReply, AgentEndedEach a value, the schema to declare {key}:reply and {key}:ended with, and the type of that event's data.
ParticipantRefWhat connect returns: key, post and release; a peer for allow.
TurnOutcome<T>An outcome, T the answer's type.
OutputSchema<T>A JSON Schema carrying the type T of what it accepts: typed gives a TypeBox schema this type, and run, stage and events take it. A declaration that needs only the type writes it, declare const X: OutputSchema<{ id: string }>.
JsonValue, JsonObjectWhat args, results, stage values, meta and tool results are.
Workflow<Args, Result, Events>What defineWorkflow returns.
DeadlineExceededError, DecisionErrorErrors, with code "deadline-exceeded" and "decision-failed". DeadlineExceededError's deadline is the Date that passed.
Duration, Timeout, WhenA duration; a call's bound, Duration | Date | false; and when a timer is due, Duration | Date.
WorkflowMeta, WorkflowInvocationWhat meta is; and what prepare is given, argv and cwd.
StageSummaryOne stage an attempt entered, as report is given it: stage, source (ran or reused) and its outcome.
RuntimeSelectionWhat runtime takes: an alias's name, an alias with constraints, or an ExecutionConfig.
ExecutionConfig, AgentPlacement, EffortA complete runtime: harness, model, effort?, placement? (tui or headless) and metered?.
AgentExecutionWhat agent.execution is: its ExecutionConfig, and the alias it was opened through.
AgentOpenSpecWhat open takes, for a helper that builds one.
RunResult<T>What run resolves to: { outcome }.
SkillRefAn item of skills.
ToolSpec<I>, ToolCallWhat tool returns; and a handler's second argument: the call's id, its agent and its signal.
SandboxRefA sandbox the run opened, to pass where an agent opens.
PeerRefWhat allow takes: an agent, the calling session or a participant.
QuestionOne of the questions choice, score and yesNo make.
WorkflowEvent, ReceivedEvent<Events>An event a wait returns: id, name, data; and any event a workflow's waits can return, its own or a timer's.
WaitOptionsThe options of receive, race and select: label?, a string or a function.
TOOL_LIMITSThe tool limits: name (a pattern), tools 16, descriptionBytes 1024, schemaBytes 8192, defaultTimeoutMs 30000, inputBytes and resultBytes 65536.

What's next is in docs/status.md.