Core concepts
Human approval
Pause a run for a human decision and resume it from code, the CLI, or the dashboard.
Some tool calls are too consequential to run unattended — refunds over a threshold, production deploys, outbound emails. Assemble handles these by pausing the run before the tool executes, persisting everything accumulated so far, and handing you an approvalId. The run resumes only when a decision arrives: from your code via resumeRun, from the CLI with --wait-approval, or from the dashboard approvals inbox.
The pause is durable. Because the partial RunState is written to disk, the approving human can take five seconds or five days — the process that started the run does not need to stay alive.
What triggers an approval#
Two mechanisms pause a run, and they compose:
- A policy
requireApprovalrule. Conditional, input-aware gating — pause only when the refund exceeds $100. See policies. - A tool with
requiresApproval: true. Unconditional — every call to that tool pauses, regardless of input. See tools.
import { tool } from "@assemble-dev/sdk";
import { z } from "zod";
export const deployToProduction = tool({
name: "deployToProduction",
description: "Deploys the current build to production",
inputSchema: z.object({ buildId: z.string().min(1) }),
outputSchema: z.object({ deployId: z.string() }),
requiresApproval: true,
execute: async (input) => await startDeploy(input.buildId),
});Use the tool flag for actions that always need sign-off, and a policy rule when the decision depends on the input.
The awaiting_approval result#
When a trigger fires, the tool does not execute. Instead, run returns:
const started = await supervised.run(
{ ticket: "refund $250 please" },
{ runId: "run_1", persistRunState: true },
);
if (started.status === "awaiting_approval") {
console.log(started.approvalId);
console.log(started.state.approvals);
}The result carries the runId, the approvalId, the partial state (a RunState with status: "awaiting_approval" and a pending entry in approvals), and the trace events so far. The state is persisted to <runStateDirectory>/<runId>.json — .assemble/runs by default — which is what makes resuming from a separate process possible.
Note:
persistRunStatedefaults totrue, so you only need to set it explicitly if you disabled it elsewhere. Pass a stablerunIdwhen you plan to resume: you need it to find the run again.
Resuming a run#
resumeRun loads the persisted state, records the decision, and either continues or resolves the run:
import { resumeRun } from "@assemble-dev/sdk";
const resumed = await resumeRun({
definition: supervised,
runId: "run_1",
decision: { approved: true, approvedBy: "[email protected]" },
});
if (resumed.status === "completed") {
console.log(resumed.output.reply);
}On approval, the pending tool call executes with the original input, and the workflow continues from the step after the one that paused — earlier steps are not re-run. The result is the same union run returns, so a resumed run can even pause again on a later approval.
Pass the same runStateDirectory in options if the run was started with a non-default one. resumeRun throws an AppError if no state file exists for the runId (NOT_FOUND) or if the run's status is not awaiting_approval (CONFLICT) — you cannot resume a run twice.
Decision shape#
type ResumeRunDecision = {
approved: boolean;
approvedBy: string;
reason?: string;
};- Approve with
{ approved: true, approvedBy: "[email protected]" }. - Reject with
{ approved: false, approvedBy: "[email protected]", reason: "amount looks wrong" }.
approvedBy is required either way and lands in RunState.approvals as decidedBy, giving you an audit trail of who decided what.
Rejected runs#
A rejected decision resolves the run as status: "rejected". The pending tool never executes, an approval.rejected event is recorded with your reason, and the persisted state marks the run failed with a descriptive error naming the tool, the step, and the decider. rejected is a terminal status — the run cannot be resumed again.
const rejected = await resumeRun({
definition: supervised,
runId: "run_1",
decision: { approved: false, approvedBy: "[email protected]", reason: "duplicate request" },
});
console.log(rejected.status);rejectedWaiting from the CLI#
asm run can block on the approval instead of exiting, which suits scripts and CI:
asm run src/workflow.ts --input '{"ticket":"refund $250 please"}' --wait-approval 300If the run pauses, the CLI holds the process open and polls the platform; when the dashboard approvals inbox records a decision, it auto-resumes the run locally and prints the final result. The optional value is a timeout in seconds — a bare --wait-approval uses the default timeout. Waiting requires a platform API key, since the decision arrives through your workspace. See the CLI reference.
The dashboard approvals inbox#
When a run uploads its trace to the platform, pending approvals appear in your workspace's approvals inbox. Each entry shows the workflow, the tool, the input it wants to run with, and the reason string produced by the policy rule — which is why returning a descriptive string from requireApproval matters. An approver clicks approve or reject, optionally with a reason, and that decision is what --wait-approval picks up. See platform for workspace setup.