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
| Call | Returns |
|---|---|
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
| Call | Returns |
|---|---|
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.execution | the 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
| Call | Returns |
|---|---|
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
| Call | Returns |
|---|---|
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
| Call | Returns |
|---|---|
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.started | the event that started the run |
agent.is(e), timer.is(e), signal.is(e) | narrows an event |
A workflow with events
| Call | Returns |
|---|---|
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
| Call | Returns |
|---|---|
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.deadline | the run's id, the attempt's number, its directory and deadline, a Date |
Results and errors
| Name | What it is |
|---|---|
| Outcomes | how a turn ended: answered, unanswered, blocked, timed-out, failed or cancelled |
| Deadlines | timeout, a duration or a Date, never past the bound around it |
duration(text) | a string read from data, checked, as a Duration |
DeadlineExceededError | what parallel, decide and a call past its scope's deadline reject with |
DecisionError | a decision that was sent and didn't answer |
Ending | how an attempt ended, parked included, as present and report get it |
typed | a TypeBox schema carrying its type, from agentswf/typebox |
| Types | the types and constants agentswf/workflow exports |
A workflow file
// 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, starts | A 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:
kind | Fields |
|---|---|
completed | value. |
parked | label?, its last wait's label, and until?, an ISO time: a workflow with events waiting with nothing to do. |
stopped, failed, timed-out, cancelled | stage?, 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
wf.agents.open
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 | |
|---|---|
key | Required. 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. |
runtime | Required. 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. |
instructions | Standing instructions, given once when the agent opens. |
skills | Exactly 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. |
sandbox | A sandbox from wf.sandboxes.open, or an inline spec for a private one. Left out, the agent is unsandboxed. |
cwd | Where the agent works; wf.cwd by default, which awf run --cwd sets. |
pane | Where its pane goes among the run's, and whether it is the one to look at: Where panes go. |
tools | What it can ask your code to do mid-turn: tool. |
timeout | Bounds the open, as for run; the enclosing scope's by default. |
The runtime fields:
| Field | |
|---|---|
harness | "claude", "codex", "pi" or "cursor". |
model | The harness's own model name. |
effort | In 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. |
metered | true: 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
const { outcome } = await reviewer.run({
prompt: "Review the diff on this branch against main.",
schema: FINDINGS,
timeout: "15m",
label: "first pass",
});| Field | |
|---|---|
prompt | Required. The turn's prompt. |
schema | A 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. |
timeout | Bounds 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. |
label | The turn's name in the progress view and the record. |
nudge | On 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.
kind | Fields |
|---|---|
answered | value, the checked answer. |
unanswered | reason. The agent stopped without an accepted answer, even after a nudge. |
blocked | reason. The agent is showing a question only a person can answer, such as a permission prompt. |
timed-out | reason. The turn's deadline passed. |
failed | reason, 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. |
cancelled | reason. 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:
if (!outcome.answered) return { error: `${outcome.kind}: ${outcome.reason}` };
outcome.value.findings; // typedA 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:
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.
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 forrun. answeredmeans it compacted.valueis 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.
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 forrun. answeredmeans the switch is in force, andagent.executionshows 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).
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
forkis 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) andruntime,{ placement?, metered?, effort? }, asopen's fields: never a harness or model, since it continues its parent's session on them. It never inheritspane: without one it gets a tab of its own.timeout, as forrun, bounds taking the copy and opening the fork. - It rejects before the parent's first turn, once the parent is closed, and after a
setqueued 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):
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? }.tabis its label, the key by default; everytabis 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: truemarks 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 --workspaceand--keep-panes, which the run keeps for every attempt. Abesidepane 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, withawf emit, has neither flag and takes the defaults. - Refused at
open:paneon a headless or sandboxed agent, and a malformed value (an unknown field, asideother thanrightorbelow, ashareout of range, an agentbesideitself). - A well-formed
panethat 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
await triager.close(); // true once closedagent.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.
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 });nullmeans 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 withoutcompactorset. Called anyway, from JavaScript,setis refused andcompactendsfailedwith the session as it was. It takes noinstructions, and stays usable after a turn that fails, is cancelled or times out. The operator interrupting a turn settles itcancelled. Where the harness's interrupt can't be recognised, an unanswered turn isn't nudged by default. Its forks areforkCaller's, and take notools. - 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.
const fixer =
(await wf.agents.forkCaller({ key: "fixer", pane: { front: true } })) ??
(await wf.agents.open({ key: "fixer", runtime: "claude" }));- Fields: those of
forkbuttools:key,runtime,instructions,paneandtimeout. Root scope only. nullmeans 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-runskill 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.
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 | |
|---|---|
description | Required. What it is for, as the agent reads it in its prompt. |
input | An 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. |
timeout | Each 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,closeandcancel()on its started turn throw, as they do on an agent whose handler ran this one's, and so doeswf.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 logsshows 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.
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.labelnames the group in the progress view ("review 2/3").timeoutbounds the whole group, as forrun.- When its deadline passes, the turns still running end
timed-out, andparallelrejects withDeadlineExceededError: the results already gathered are dropped. - It's fail-fast. If one item throws, the others are cancelled and
parallelrejects. 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.
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 | |
|---|---|
key | Required. The sandbox's name in this run. |
cwd | Its working directory, wf.cwd by default; always readable inside. |
read | Host paths visible read-only, at the same path, beyond the working directory. |
write | Host paths visible and writable. "." makes the working directory writable. |
network | Domains reachable beyond each harness's model API: exact, or *.example.com for its subdomains only. No scheme, port, path or IP address. |
sockets | Host 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. |
webSearch | true keeps the harnesses' model-side web tools, which are off inside otherwise. |
srt or docker | Required, 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
networkrule, so withwebSearch: trueit can carry out what the agents read. claude'sWebFetchgoes 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_TOKENand cursorCURSOR_API_KEYin 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.jsonputs 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.
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 | |
|---|---|
key | Required. 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. |
model | Required. A decision model alias: jev is Jev through OpenRouter, which needs OPENROUTER_API_KEY in the environment or a .env where you run awf. |
state | Required. What every question is about: a string, a JSON object or an array. |
questions | Required, at least one. Each is answered on its own. |
timeout | As 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):optionsmaps each name to a description, ornull. Its answer haschoice, the most likely (the first on a tie), andprobabilitiesby name.yesNo(instructions, { yes, no }?): its answer'syesis the probability of yes.score(instructions, levels):levelslowest first. Its answer haslevel, the most likely index (the lowest on a tie),expected, the probability-weighted level, andprobabilities, 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.
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, to | One way: from may message to, which answers when asked. |
between: [a, b] | Both ways, sharing one count. Give this or from and to. |
purpose | Required. Shown with every message on the route. |
maxMessages | Lowers the ceiling of 40 messages a route an attempt, replies included. |
timeout | A 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, andwfa 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-replyworks on headless claude, codex and pi, and on a claude TUI agent that confirms what it is sent. Elsewhere it is refusedunsupported, a codex TUI agent included, and the agent sends without waiting.- Recorded:
wf.usage()marks each operation a message started, andawf run,awf logsandawf runsshow 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.
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.labelsis any JSON object for whatever answers; awf passes it along inawf inboxand never reads it.- Agents talk to it as to any peer:
wfa send coordinatorreports, andwfa send --await-reply coordinatorasks; 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 resolvesfalsewhen already released. - Run in a terminal,
awf runasks each question there. Otherwise the answer comes fromawf reply, by a session or an app readingawf inbox. participant.post(text)writes to it without a turn: an item inawf 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.
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.versionguards reuse: a record from another major version (another minor under0.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.
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
}
}
},
});awf emit --target case.ts --id checkout-timeouts dd.changes '{"case":"checkout-timeouts"}' --event-id 83d34d7The fields defineWorkflow adds:
| Field | |
|---|---|
events | Required. 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. |
starts | The 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. |
file | import.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, orwf.emit's second argument;wf.runIdis the id and the ref is{workflow}/{id}. Withoutid(args)or--id,awf runrefuses a run of a workflow withstarts, 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 startingawf.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 ignoresstarts, 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, orawf tickonce 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
besideit 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.raceis 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 areceiveorselecttook 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.returnends the run for good: later events for it are refused.- Without
eventsa workflow waits as any does, withreceive,race,select,timerandsleep, but never parks: it waits in its process. It declares no name, sowf.signaltakes none, andagent.startandkeepAlivethrow, 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 | |
|---|---|
label | What 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 | |
|---|---|
id | Its id: the sender's eventId, or one awf made, starting awf.. |
name | A declared name, {key}:reply and {key}:ended included, or timer:due. |
data | Typed by its schema; for awf's own, as agent.start, a participant's messages and wf.timer say. |
from | An agent's or participant's key, a timer's name, the sending run's ref for wf.emit, or operator for awf emit. |
at | When 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.
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,
openrefuses 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:
// 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:
// 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));
}await wf.stage("release", () => release(wf.runId));
await wf.sleep("follow-up", "14d");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 | |
|---|---|
name | Required. Where the answer is recorded. Shared with sleeps; a race's own timers are {name}@{branch}. |
branches | Required, at least one, none named key. Each is a source or a duration. |
options.label | What 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
atwins; 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; awf.timer()passed in is the code's to cancel. A branch takes a duration, not aDate, and notwf.at: a fixed time iswf.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 | |
|---|---|
branches | Required, at least one, none named key. Each is a source or a wf.at. |
options.label | A 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 | |
|---|---|
name | Required. Where the time is recorded; no @, which a race's own timers use. |
when | Required. 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
Datefrom the run's files: one built fromnow()differs on every rerun. timer.cancel()stops it, and atimer:dueof it not yet taken is withdrawn; the name stays cancelled.- In a
receiveloop,timer.is(e)tells its event, ore.name === "timer:due"withe.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.
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.
readis 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()andwf.after()throw inside it, as does returning anything but a validDateornull.- 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
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 sessionsfields 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.
// 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.runIdis the run's id, the same in every attempt.wf.attemptis whichawf runof the run this is: 1, then one more for each continue.wf.cwdis the working directory, whichawf run --cwdsets.wf.deadlineis the run's deadline, aDate, absent unlessawf run --timeoutsets one. Read it withwf.deadline.getTime().
typed
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. |
AgentRef | An agent the run opened or forked: a CallerRef, and compact and set. |
CallerRef | What 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. |
EventSchemas | What events is: each name's schema. |
ParticipantReply, AgentEnded | Each a value, the schema to declare {key}:reply and {key}:ended with, and the type of that event's data. |
ParticipantRef | What 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, JsonObject | What args, results, stage values, meta and tool results are. |
Workflow<Args, Result, Events> | What defineWorkflow returns. |
DeadlineExceededError, DecisionError | Errors, with code "deadline-exceeded" and "decision-failed". DeadlineExceededError's deadline is the Date that passed. |
Duration, Timeout, When | A duration; a call's bound, Duration | Date | false; and when a timer is due, Duration | Date. |
WorkflowMeta, WorkflowInvocation | What meta is; and what prepare is given, argv and cwd. |
StageSummary | One stage an attempt entered, as report is given it: stage, source (ran or reused) and its outcome. |
RuntimeSelection | What runtime takes: an alias's name, an alias with constraints, or an ExecutionConfig. |
ExecutionConfig, AgentPlacement, Effort | A complete runtime: harness, model, effort?, placement? (tui or headless) and metered?. |
AgentExecution | What agent.execution is: its ExecutionConfig, and the alias it was opened through. |
AgentOpenSpec | What open takes, for a helper that builds one. |
RunResult<T> | What run resolves to: { outcome }. |
SkillRef | An item of skills. |
ToolSpec<I>, ToolCall | What tool returns; and a handler's second argument: the call's id, its agent and its signal. |
SandboxRef | A sandbox the run opened, to pass where an agent opens. |
PeerRef | What allow takes: an agent, the calling session or a participant. |
Question | One 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. |
WaitOptions | The options of receive, race and select: label?, a string or a function. |
TOOL_LIMITS | The 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.