Pause seam - agbossjs/agboss GitHub Wiki
A run ends at a workflow finish and the next question is a new run in the same thread — unless a
tool needs a decision first. Then the run pauses: app.execute returns status: "paused" with
the waiting call in pause, and app.resume(thread, decision) continues it. The core defines where
and how, so a pause never contradicts limits, spend recording or Terns.
A tool that needs approval names a channel — who is asked — and the workflow lists the channel
classes it uses (TerminalUserChannel asks in the terminal; AutoApproveChannel /
AutoRejectChannel for tests and scripts; or your own @Channel class):
@Tool({
name: "send_email",
description: "Sends an email",
input: EmailDraft,
output: EmailSent,
channel: TerminalUserChannel, // the call waits for a decision
})
export class SendEmail implements ToolHandler<EmailDraft, EmailSent> {
run(draft: EmailDraft): Promise<EmailSent> {
return Promise.resolve({ id: `sent to ${draft.to}` });
}
}
@Agent({ name: "assistant", description: "Does errands", prompt: "Do the errand.", model: "moonshotai/kimi-k2.6", tools: [SendEmail] })
export class Assistant {}
@Workflow({
name: "errands",
version: "1.0.0",
flow: [chain(TaskStart, Assistant, Done)],
defaults: { models: { temperature: 0 }, router: { kind: "jev", model: "typesafe/jev-1.13" }, history: { limit: 5 } },
channelClasses: [TerminalUserChannel], // a tool's channel missing here fails assembly
})
export class Errands implements WorkflowDefinition {
settings() {
return WorkflowSettings.builder().build();
}
}
const app = await createApp(Errands);
const first = await app.execute(TaskStart, { text: "Email the report to my boss" });
if (first.status === "paused") {
// first.pause = { agent, callId, tool: "send_email", args: { to: "…", body: "…" } } → ask someone
const done = await app.resume(first.thread, { approved: true, by: "pavel" });
console.log(done.output?.text);
}The decision is a ToolCallApprovalDecision (agboss/dto): approved, by — who or what
decided, feedback? — why, overrideArguments?. It is validated on resume (DtoValidationError
otherwise). agboss chat asks in the terminal and fills by with the OS user name; a decline carries
feedback: "declined in the terminal". sensitive fields of the input DTO are shown as ***.
A tool may also pause to ask a question itself: ctx.pause(question) with a
WorkflowPauseQuestion (question, options?) — the run pauses with pause.kind: "interactive",
and the value given to resume is what ctx.pause returns.
Paused runs live in the app's pausedRuns repository — in memory by default;
createApp(W, { pausedRuns: createSqlitePausedRunRepository(path) }) keeps them across restarts.
flowchart LR
B[boundary: guardrails] --> P[approval: interrupt, one call]
P -- resume --> P
P --> T[tool runs once]
T --> C[collect] --> M[model: next turn]
- Inside the agent's loop (Agents) a call whose tool needs approval is not executed:
after the
beforeToolCallguardrails, the loop'sapprovalnode calls LangGraphinterrupt({ agent, callId, tool, args })— the run is paused, not finished. The loop is a compiled subgraph inside the agent's flow node, so everything before the pause is checkpointed and never repeated; the spend so far is read from the loop's checkpoint and written to the ledger at the pause. - One call per pause: two calls needing approval in one model turn → two pauses, in call order.
-
app.resume(thread, decision)continues the same step: approved → the tool runs once; rejected → the model readsTool error: the call was rejected by <by>[: <feedback>]. The model is not asked again for the turn that asked for the call — it continues with the results.
| # | Rule |
|---|---|
| 1 | Spend before the pause is already in the ledger; on resume nothing is recorded twice |
| 2 | The run budget left at the pause is checkpointed and carried over |
| 3 | The Tern is written with status paused and completed (same id) on resume |
| 4 | Guards, limits, agent limits, history apply to the paused-and-resumed run as one run |
| 5 | A rejection reaches the model as a tool error |
After the decision and the agent's answer the router sends the turn to its workflow finish without a
model call (#100). Resuming a run that is not paused (or twice) → NotPausedError. A failure after resume completes
the same Tern as failed. Decision recorded in ADR-0004.
The loop goes on after the decision: the model reads the call's result (or the rejection) and
answers. Then the turn ends — the router is not asked again
(stop: the agent answered after the approval decision); a follow-up goes to the next turn (#100).