Agent Workflows
A composite tool calls other tools and subagents in code. Use it when an operation needs a fixed sequence, parallel checks, or a structured result. Durable execution adds recorded steps and pause and resume. Use a single tool for a self-contained calculation or API action.
Composite Tools
Use handler.ts with tool.json, and declare composition in tool.json:
amodal/tools/review_request/
├── tool.json
└── handler.ts{
"name": "review_request",
"description": "Load a request, review completeness and policy, and save the findings.",
"parameters": {
"type": "object",
"properties": { "request_id": { "type": "string" } },
"required": ["request_id"]
},
"uses": {
"tools": ["load_request", "persist_review_findings"],
"subagents": ["prescreen-reviewer", "policy-reviewer"]
}
}export default async function (params, ctx) {
const request = await ctx.callTool('load_request', {
request_id: params.request_id,
});
const [completeness, policy] = await Promise.all([
ctx.callSubagent('prescreen-reviewer', 'Check field completeness.', request),
ctx.callSubagent('policy-reviewer', 'Review policy requirements.', request),
]);
const findings = { completeness, policy };
await ctx.callTool('persist_review_findings', {
request_id: params.request_id,
findings,
});
return { request_id: params.request_id, findings };
}The example requires the two named child tools and two authored subagents. Subagent results are text. Parse and validate them in a child tool if your product needs structured fields.
Do not add tool.ts to this directory. A tool.ts takes precedence over tool.json, and that layout does not bind ctx.callTool or ctx.callSubagent.
Composition Semantics
- Declare every child tool in
uses.toolsand every specialist inuses.subagents. A declared name that does not resolve fails at bundle load. A call to an undeclared name fails at run time. ctx.callTool(name, params)executes the child tool and returns its result.ctx.callSubagent(name, task, input?, opts?)runs the specialist's agent loop and returns its final text.inputis appended to the task as a JSON block.- A failed inline child call throws a
CompositeCallErrorwith acode:depth-exceeded,undeclared,unknown-tool,confirmation-unsupported,invalid-params,execution-failed,subagent-undeclared,subagent-unknown, orsubagent-failed. - Inline
ctx.callToolnesting stops at depth 4. Subagent nesting stops at five levels. - Nested tool calls render as nested cards in the chat UI.
Nested tool calls do not run the outer loop's permission or hook checks. Inline composites refuse tools marked requiresConfirmation and connection-category tools, including request, with confirmation-unsupported. They do not open an approval dialog.
Durable child execution does not apply that confirmation check. Use an explicit durable approval before a write that needs an operator decision. A uses declaration limits reachability. Child code and downstream APIs must enforce authorization.
Fanning Out Attachments
Top-level handlers receive the current turn's uploads in ctx.attachments. Pass selected files in the fourth argument to a specialist:
export default async function (params, ctx) {
const results = await Promise.all(
(ctx.attachments ?? []).map((file) =>
ctx.callSubagent(
'document-extractor',
"Extract the document's facts and cite the supporting text.",
{ filename: file.filename },
{ attachments: [file] },
),
),
);
return { results };
}Declare document-extractor in uses.subagents. Attachments become file parts on the specialist's user turn. For providers without media support, they are dropped and the runtime logs subagent_attachments_stripped.
Durable Tools
Use durable execution when a tool must pause for input, approval, or a future time. The handler runs again from the top on resume. Journaled calls return their recorded results instead of executing again.
Set "execution": "durable" in tool.json:
{
"name": "release_to_environment",
"description": "Ask for a target environment and release to it.",
"execution": "durable",
"parameters": { "type": "object", "properties": {} },
"uses": { "tools": ["run_release"] }
}export default async function (params, ctx) {
const target = await ctx.requestInput({
question: 'Where should this release run?',
inputType: 'choice',
options: [
{ value: 'staging', label: 'Staging', recommended: true },
{ value: 'production', label: 'Production', sub: 'Makes the release live' },
],
});
const result = await ctx.callTool('run_release', { target: target.value });
return { target: target.value, result };
}The example requires a run_release child tool that validates the target and performs the release.
| Method | Contract |
|---|---|
ctx.requestInput(opts) | Pause for an answer. Supports question, context, inputType, options, payload, allowEdits, eligibleAnswerers, and excludeAnswerers. Returns {value, respondedBy, edits?}. |
ctx.waitForApproval(prompt) | Yes/no form of requestInput. Returns {approved, respondedBy?}. |
ctx.step(name, fn) | Run an async checkpoint and record its JSON result. On replay, return that result without calling fn. Repeated names are distinguished by call order. |
ctx.sleepUntil(when) | Pause until a Date, then resume through the scheduler. |
ctx.now() | Recorded clock value in milliseconds. |
ctx.random() | Recorded random value. |
inputType accepts text (the default), choice, or yesno. Choice options have a value and label. recommended and sub affect presentation. The returned value is whatever the answerer posted, so validate it before a sensitive action. eligibleAnswerers and excludeAnswerers are lists of user IDs.
Use ctx.step for handler-local work whose result must survive replay:
const record = await ctx.step('parse-input', async () => JSON.parse(params.document_json));Return plain data from checkpoints. A Date returns as an ISO string, and a class instance returns as a plain object. Mutations to variables outside fn are not restored on replay.
Route external I/O through declared child tools. In a durable handler, ctx.request, ctx.store, and ctx.exec reject, and ctx.env returns undefined. The handler runs again from the top on each resume, so any work outside journaled calls runs again.
When a durable tool pauses during chat, the model receives {durable: true, status: "running", session_id, message} and the turn continues. When the tool is waiting on requestInput, the result is {durable: true, status: "awaiting_operator", session_id, question, context, message}. The run's status is awaiting-input. Persistence across restarts requires a persistent session store. Local amodal dev keeps sessions in memory.
Answering A Pause
Answer the durable session named by session_id in the pause result. Its ID differs from the chat session's ID.
curl "$RUNTIME/sessions/pending-approvals" \
-H "Authorization: Bearer $RUNTIME_TOKEN"
curl -X POST "$RUNTIME/api/sessions/$DURABLE_SESSION_ID/answer" \
-H "Authorization: Bearer $RUNTIME_TOKEN" \
-H "Content-Type: application/json" \
-d '{"value": "staging"}'The body requires value. With allowEdits, include edits with the edited payload. A completed run returns 200 with outcome and result. A run that pauses again returns the next pause in outcome.
| Status | error | Cause |
|---|---|---|
400 | missing_value | The body has no value. |
403 | not_eligible_answerer | The caller is not in eligibleAnswerers. |
403 | excluded_answerer | The caller is in excludeAnswerers. |
404 | session_not_found | No such session, or it belongs to another scope. |
409 | session_not_awaiting_input | The pause was already answered. The body includes answeredBy if known. |
POST /api/sessions/:id/cancel stops a parked run. Answer and cancel publish session_updated events on GET /api/events.
In the React SDK, useSessionActions(id).answer(value, edits?) answers from an inbox or custom screen. The chat widget renders inline choice, yes/no, and text cards and answers them through the same endpoint.
Triggers
| Entry point | Use |
|---|---|
| LLM tool selection | The model chooses the tool in response to a user message. |
| Predicate trigger | A regex or predicate entry in the tool's triggers starts the tool from a matching message without model selection. |
| Direct invocation | Your app calls POST /api/tools/:name/run for a durable tool whose triggers include { "kind": "invoke" }. |
| Automation | A platform automation runs the tool on a schedule or event. |
tool.json accepts a { "kind": "schedule", "cron": "..." } trigger, but the runtime does not fire it. Schedule the tool through an automation.
A trigger does not remove the tool from the model's tool list. tool.json has no llm_callable field; only the tool.ts layout can set llm_callable: false, and that layout cannot compose.
POST /api/tools/:name/run takes {"input": {...}} and optional session_id, scope_id, and context. It returns 403 tool_not_invocable without an invoke trigger and 400 tool_not_durable for an inline tool. It does not validate input against a tool.json parameter schema, so the handler must validate it.
Before deploying, cover denied approval, missing data, child-tool failure, and replay after a pause with evals.