Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Agent Workflows

Amodal agents work best when the app's critical workflow is not left to a single free-form chat turn. Put the repeatable parts in code, delegate judgment to focused subagents, and make the runtime show every step it took.

This guide describes the workflow pattern used for high-stakes product agents:

  • Tools encode deterministic business actions.
  • Composite tools call other tools and subagents in a fixed order.
  • Subagents perform narrow judgment tasks with scoped prompts and capabilities.
  • Triggers decide how a workflow starts: LLM call, operator action, scheduled job, or external event.
  • Durable execution lets a workflow pause for human approval, resume later, and keep an auditable trace.

Use this pattern when the agent must operate the product, not merely answer questions about it.

When To Use A Workflow

Use a composite or durable workflow when any of these are true:

  • The agent must create or update several records.
  • The same checklist must run every time.
  • A model might skip a required step if asked to reason conversationally.
  • The flow needs a human approval gate before writing.
  • The user needs progress updates while the agent works.
  • The result needs an audit trail: inputs, tool calls, subagent judgments, approval, and final state.

For example, an operations agent should not simply say, "I found three issues." It should load the relevant records, inspect every item in scope, run deterministic validation and policy checks, ask specialized subagents to review the evidence, persist the findings, and offer approved corrections through product APIs.

The Authoring Model

Think of the system as one authored capability with two external axes:

ConcernWhere It Lives
Product actionTool handler code
Multi-step orchestrationComposite tool code
Model judgmentSubagent call
How it startsTrigger or LLM tool selection
Whether it can pauseInline vs. durable execution
What the user seesStreamed tool, reasoning, progress, and approval events

This separation is the core design rule. Do not create a new abstraction every time a workflow needs a slightly different entrypoint. The same review workflow can be started by an LLM tool call, a button, a schedule, or an event; the workflow itself should stay the same.

Composite Tools

A composite tool is a custom tool whose handler orchestrates other capabilities. It can call deterministic child tools with ctx.callTool and focused model agents with ctx.callSubagent.

Use child tools for work that should be repeatable:

  • load a record
  • validate fields
  • calculate risk
  • call an external API
  • write a database update
  • persist an audit row

Use subagents for bounded judgment:

  • summarize a source document
  • review a request against policy text
  • explain why a deterministic check matters
  • rank candidate fixes
  • draft operator-facing language

The composite handler owns the order. The model no longer has to remember, "first run prescreen, then policy review, then package readiness, then aggregate." The handler does that every time.

export default {
  id: "review_request",
  exposure: { kind: "open" },
  llm_callable: true,
  base: {
    name: "review_request",
    description: "Review one request across completeness, policy, and package readiness.",
    parametersJsonSchema: {
      type: "object",
      properties: { request_id: { type: "string" } },
      required: ["request_id"],
    },
  },
  async handle(ctx) {
    const request = await ctx.callTool("load_request", {
      request_id: ctx.input.request_id,
    });
 
    const [prescreen, policy, packageReadiness] = await Promise.all([
      ctx.callSubagent("prescreen-reviewer", "Review field completeness.", request),
      ctx.callSubagent("policy-reviewer", "Review coordination and policy triggers.", request),
      ctx.callSubagent("package-reviewer", "Review export package readiness.", request),
    ]);
 
    const findings = mergeFindings([prescreen, policy, packageReadiness]);
    await ctx.callTool("persist_review_findings", {
      request_id: ctx.input.request_id,
      findings,
    });
 
    return { request_id: ctx.input.request_id, findings };
  },
};

Declaring Composition (uses)

A composite tool must declare everything it calls in its tool.json — the allowlist is fail-closed on both the inline and durable paths, so a ctx.callTool / ctx.callSubagent against anything undeclared rejects at runtime with an error naming the missing declaration:

{
  "name": "review_request",
  "uses": {
    "tools": ["load_request", "persist_review_findings"],
    "subagents": ["prescreen-reviewer", "policy-reviewer", "package-reviewer"]
  }
}

This keeps the capability graph static: what a tool can reach is visible in its manifest — auditable before deploy — rather than discovered at runtime. Adding a new child call is a two-line change (the call and its declaration), and forgetting the declaration fails loudly on first execution, not silently.

Composition Semantics

  • Nested calls render in the chat. A composite's ctx.callTool emits nested tool-call cards between the parent tool's start and result, so the user watches sub-steps stream instead of one opaque parent card.
  • Subagents run the real agent loop — their own authored prompt and declared tool subset, to completion, returning their final text. A failed subagent run surfaces as a thrown error in the composite handler; catch it if the workflow can proceed without that judgment.
  • Recursion is bounded. Each nested dispatch runs one level deeper, so subagent → composite → subagent chains can't run away.
  • Inline vs. durable composites share the same surface. The same callTool / callSubagent code works in both; declaring "execution": "durable" adds journaling (child calls replay idempotently across pauses — see Durable Tools) and the pause primitives, and in exchange removes raw ctx.request / ctx.store (route external I/O through a child tool so it journals).

Fanning Out Attachments

When the user's message carries uploaded documents, a top-level tool sees them as ctx.attachments (empty or undefined on turns without uploads). A composite tool can forward them to subagents through the fourth argument of ctx.callSubagent, so each subagent reads the source file directly instead of a lossy text summary:

async handle(ctx) {
  const extractions = await Promise.all(
    (ctx.attachments ?? []).map((file) =>
      ctx.callSubagent(
        "document-extractor",
        "Extract structured facts from this document.",
        { filename: file.filename },
        { attachments: [file] },
      ),
    ),
  );
  // merge extractions, persist, return findings
}

Each attachment is delivered onto the subagent's user turn as a file part, the delivery path every multimodal provider reads.

Durable Workflows

Use durable execution when a workflow can outlive one chat turn or needs a human decision before it writes. A durable workflow should be written as linear code, but all side effects must go through journaled runtime calls so resume does not repeat work.

Good durable boundaries:

  • approving a batch of generated records
  • waiting for a vendor callback
  • sleeping until a retry window
  • asking a second operator to authorize a high-risk change
  • applying a proposed correction after review

Authoring rule: keep durable handlers replay-safe. Route time, random values, external I/O, child tool calls, and subagent calls through the runtime context rather than raw global calls.

Durable Tools

Declare a tool durable in its tool.json with "execution": "durable". It then runs on the replay engine — journaled and resumable — and its handler ctx gains the durable primitives:

PrimitiveDescription
ctx.requestInput(opts)Pause until an operator answers. opts supports a question + context, an inputType of text / choice (with options) / yesno, a reviewable payload (with allowEdits returning the operator's edits), and eligibleAnswerers / excludeAnswerers allow/block lists. Resumes via POST /api/sessions/{id}/answer; the posted value comes back verbatim (any JSON shape).
ctx.waitForApproval(prompt)The yes/no convenience over requestInput — resolves to { approved, respondedBy?, note? }.
ctx.step(name, fn)Journaled checkpoint for handler-local work — arbitrary expensive or non-deterministic code that isn't a callTool/callSubagent (parsing a PDF, hashing a file). Runs fn once and records its result; on resume replay the recorded result is returned without running fn again. Reuse the same name inside loops — an ordinal counter disambiguates repeat call-sites. Two rules follow: only the return value survives — don't mutate outer variables inside fn (those effects are gone on the resumed re-run; route everything out through the return value), and the result is journaled as JSON — the JSON-normalized value is what step returns on every run, so a Date comes back an ISO string and class instances come back plain objects. Return plain data.
ctx.sleepUntil(when)Suspend until a wall-clock time, then resume (scheduler-backed).
ctx.now() / ctx.random()Replay-frozen clock and randomness — use instead of Date.now() / Math.random() so replays are deterministic.
// tools/deploy_release/handler.ts — tool.json: { "execution": "durable", ... }
export default async function deployRelease(params, ctx) {
  // Checkpointed handler-local work: parses once, replays from the journal.
  const manifest = await ctx.step("parse-manifest", () =>
    parseReleaseManifest(params.manifest_url),
  );
  const target = await ctx.requestInput({
    question: `Where should we deploy ${manifest.version}?`,
    inputType: "choice",
    options: [
      { value: "staging", label: "Staging", recommended: true },
      { value: "production", label: "Production", sub: "goes live immediately" },
    ],
  });
  const result = await ctx.callTool("run_deploy", { target: target.value });
  return { deployed: target.value, result };
}

While parked, the run shows as awaiting-input in the sessions list and the pending-approvals inbox. In a live chat the model sees the tool as "started, will finish asynchronously" and moves on — it never blocks or polls. Because the pause is journaled, the parked run survives restarts (with a persistent session store; local amodal dev uses an in-memory store).

The replay rule of thumb: handler code re-runs on resume; journaled calls don't. callTool / callSubagent / step / the pauses all return their recorded results on replay — so parse-manifest above runs once, and the subagent or child tool behind a journaled call is never re-executed. Anything outside those seams (a raw fetch, Date.now(), Math.random()) re-runs on every replay — wrap it in ctx.step or a child tool, or use ctx.now() / ctx.random().

Answering a Pause

A requestInput pause is resolved by POSTing the answer to the parked durable session (not the chat session):

# Find parked runs — each entry carries the pending question/options/context.
curl $RUNTIME/sessions/pending-approvals
 
# Answer: `value` resumes the handler verbatim (any JSON shape — a string,
# boolean, or an option's structured value). With `allowEdits`, include the
# operator-modified payload as `edits`.
curl -X POST $RUNTIME/api/sessions/{session_id}/answer \
  -H 'content-type: application/json' \
  -d '{"value": "staging", "edits": {"steps": ["build", "canary", "deploy"]}}'

The handler's await ctx.requestInput(...) resolves with { value, respondedBy, edits? } and the run continues to completion (or its next pause). The endpoint enforces the request's answerer lists — a caller outside eligibleAnswerers gets 403 not_eligible_answerer — and a pause that was already resolved returns 409 session_not_awaiting_input with answeredBy, so concurrent answerers can render who won. Answer, cancel, and resume transitions are also published on the runtime event bus (GET /api/events, session_updated with status + answeredBy) for live subscribers.

In a live chat, the pause renders as an inline card in the chat UI — approve/decline buttons for yesno, an option list for choice (the recommended option highlighted, sub as secondary text), or a free-text field for text. Answering from the card POSTs the value to the same endpoint, then sends a short follow-up turn (e.g. Answered the pending "deploy_release" request: Staging) so the model acknowledges the decision and continues the conversation. A card someone else resolves — from the approvals inbox or another viewer — flips to "answered by X" via the event bus. The card carries the answer; the tool receives the option's value verbatim, so keep labels honest about what their values do.

From an embedding app, the React SDK exposes the same operations: useSessionActions(id).answer(value, edits?) for inbox-style surfaces, and the ChatWidget wires the inline card automatically.

Triggers

The workflow should not care why it started. Bind startup externally:

TriggerUse It For
LLM tool selectionUser asks naturally: "review this request"
Predicate triggerExact command or record pattern should fire deterministically
Operator buttonUser clicks "Run review" or "Apply correction"
ScheduleNightly sync, recurring digest, stale-record check
EventNew document uploaded, new request submitted, webhook received

Prefer deterministic triggers when the task is known and high-value. The LLM can still summarize results after the workflow finishes.

Example: Document Packet To Workspace

For an enterprise operations workflow, the user may hand the agent a packet of documents and say: "Build the workspace and show us what needs attention."

That should be a workflow, not a chat-only prompt. A good composite/durable tool would do this:

  1. Read the uploaded documents, tasking text, item lists, dates, parties, and constraints.
  2. Resolve structured facts against app catalogs: owner, workspace, dates, participating teams, and relevant assets.
  3. Create or update the workspace container.
  4. Create the initial set of child records from grounded activities in the packet.
  5. Attach source documents, locations, references, and supporting evidence where the packet provides them.
  6. Run item-level review for every created record.
  7. Run workspace-level review across the full package.
  8. Run domain-specific checks where required.
  9. Summarize hard stops, coordination requirements, missing information, and low-risk auto-approval candidates.
  10. Propose concrete fixes: change a field, adjust a window, attach evidence, assign an owner, or request missing data.
  11. Ask the operator to approve fixes before writing.
  12. Apply approved fixes through the product API.
  13. Rerun the relevant review.
  14. Leave the user in the populated workspace with tables, review findings, and audit trail updated.

The important distinction is that document extraction is only the first step. The workflow must leave product state behind.

Product State Beats Chat Memory

Long-running product agents should not rely on prior chat turns as the source of truth. A workflow should load current state from the product API at the moment it runs:

  • selected page and entity context
  • current user and permissions
  • records and child records
  • catalog entries
  • connected external systems
  • previous review findings and decisions

The chat transcript can explain the work, but product APIs decide what exists.

Approval And Audit

Use confirmation or durable approval when the workflow writes records, changes workflow state, approves/denies an item, or applies a generated correction.

A reviewable workflow should record:

  • input document or prompt reference
  • loaded product records
  • deterministic tool calls
  • subagent judgments
  • generated findings
  • proposed changes
  • approval decision
  • applied writes
  • rerun review result

This gives operators a defensible chain: why the agent recommended an action, who approved it, and what changed.

Design Checklist

Before building a workflow, answer these questions:

  • What product state must exist at the end?
  • Which steps are deterministic and belong in tool code?
  • Which steps require model judgment and deserve a subagent?
  • Which writes require approval?
  • What progress should the user see while it runs?
  • What records must be persisted for audit?
  • What should happen if a child tool or subagent fails?
  • Does the workflow need to resume after approval or a webhook?
  • Can the same workflow be triggered from chat, a button, and an event?

If the answer is "the LLM will remember to do it," move that step into a composite tool.