Repository navigation
Pauses
A tool that needs a decision names a channel (Tools → Approval channels);
when its channel answers ChannelOutcome.awaitReply() the call pauses until someone answers it.
An execution may wait for several answers at once — parallel branches each pause on their own —
and each pause is answered by its id (#267). How a pause maps onto LangGraph and the agent's
loop: Pause seam.
A runnable example:
examples/flow-patterns/src/approvals/— two approvals at once, answered in the terminal (Example, #346).
A paused branch of a parallel launch does not stop its siblings: they run to their end
in the same pass (a paused branch frees its maxConcurrency slot), and the execution rests only
when nothing else can move — the join waits only for the paused ones. result.pending lists every
waiting pause, one PendingPause each:
| Field | What it is |
|---|---|
pauseId |
what app.resume takes — only for a PauseAnswerer.ThreadOwner pause (below) |
answeredBy |
who may answer it: PauseAnswerer.ThreadOwner or PauseAnswerer.Channel (its role's) |
agent, channel
|
whose tool call waits, and the channel asked (a role's name or a @Channel's) |
itemIndex |
the item of a per-item launch it waits in — no copy of the item |
childExecutionId |
the child execution it waits in (Workflow as a step); undefined in the root |
ask |
the question: the tool call (ToolCallApprovalAskDto), sensitive fields masked |
A pause inside a child workflow is its root's: it is listed in the root's pending (with the
child's childExecutionId, and for a per-item child its itemIndex), several children may wait at
once, and app.resume({ pauseId, decision }) answers it like any other — the child goes on with the
id it paused with, then its parent.
status is ExecutionStatus.Paused while pending is not empty; a finished execution has
pending: []. onExecutionPause (event.pending) fires each time the execution rests because
nothing else can move — again after a partial resume when others remain; a branch re-entering its
own waiting pause fires nothing.
// a Slack webhook: the finance manager pressed "Approve"; the message's reply key is the pauseId
const result = await app.resume(
{ pauseId: replyKey, decision: { approved: true } },
{ tenantId: "acme", answeredBy: "manager-17" },
);
console.log(result.threadId, result.executionId, result.pending.length);-
By its id, always — one way, also when there is only one pause, and without a
threadId: the pause is found by(tenantId, pauseId)anywhere in the execution tree (a parallel branch, a child at any depth) and the runtime wakes the chain up to the root. An unknown id, or another tenant's, is the sameUnknownPauseError(pause.unknown), with no hint that the id exists elsewhere. -
The rest keep waiting: with other pauses left the execution pauses again with them
(
status: Paused, the rest inpending); the last answer lets it go on to its finish. The result carriesthreadIdandexecutionId, so a webhook that needs them gets them back. -
The decision is validated before it is recorded: a
ToolCallApprovalDecisionDto—approved,reason?,overrideArguments?(only withapproved: true, and a valid input of the tool) — elseDtoValidationErrorand the pause keeps waiting. The same check applies to a channel'sChannelOutcome.decided(…)(the step fails). Approved → the tool runs once (with theoverrideArguments, if given); rejected → the model readsTool error: the call was rejected by <who answered>[: <reason>]. -
app.cancel(threadId)stays by thread — a conversation is cancelled, not a pause: its pauses are withdrawn.
A webhook that only has the person's words — a Slack thread reply, a mail — passes them as they are; the pause's role reads them with its reply interpreter (#267 PR 3), named on the role:
@ReplyInterpreter({
name: "refund-reply-interpreter",
description: "Turns a manager's free-text reply into a refund decision",
decidedBy: DecidedBy.Model,
model: "moonshotai/kimi-k2.6",
prompt:
"The manager answered a refund approval in free text. Decide approve / reject and any changed amount.",
})
export class RefundReplyInterpreter {}
@ChannelRole({
name: "refund-approval-channel",
description: "Approves card refunds",
approves: RefundCardInputDto,
answeredBy: PauseAnswerer.Channel,
replyInterpreter: RefundReplyInterpreter, // one interpreter belongs to one role
})
export abstract class RefundApprovalChannel extends ChannelOf<RefundApprovalChannel> {}
// the webhook passes the text as it came; the result is the same as with a decision
await app.resume({ pauseId: replyKey, reply: "ok, but only half" }, { answeredBy: "manager-17" });-
Exactly one of
decisionorreply— by types (ResumeRequest = DecisionResumeRequest | ReplyResumeRequest) and at run time: both, or an emptyreply, isDtoValidationErrorbefore the pause is looked up. The credential (ownerId/answeredBy) is checked as for a decision. -
The interpreter gets the pause's original question and the reply —
{ ask, reply }, where the amount to halve is inask.arguments— and what it gives is validated like a decision (the role's answer DTO:ToolCallApprovalDecisionDtofor anapprovesrole,overrideArgumentschecked by the tool's input). Code downstream decides by types, never by the text. -
DecidedBy.Model— a chat model answers through one answer tool of the role's answer DTO; its staticprompt/promptUrlsare its instructions, the question and the reply come in the input message (### ask/### reply, #270 Q51). For an approval role the model may be a decision model (Jev, #267 S124 a): it reads approve / refuse only — oneDecision.noul("approved")about the question and the reply (the same input message, as the decision's state), the interpreter'spromptafter the framework's question as its instructions; approved when P(yes) > 0.5, the answer{ approved }— never areasonoroverrideArguments. A question role (ask+answer) on a decision model is[reply-interpreter.decision-model]at assembly: its answer needs more than a yes / no can give (the message names the answer DTO). Either model's call goes through the provider its model resolves to byserves([model.unserved]like any model), is checked against the tree's budgets before it is made (an exhausted one →BudgetExceededError, the pause untouched), is counted in their ledger at once and in the execution'sspendwhen the step takes the answer — once — and is traced under the pause's execution (cost callerreply-interpreter:<name>). Its model reads the question and the reply through the application's and the workflow's PII policies (#120), like an agent's: areplaceWithToken()value is a token for the model, its answer is revealed before it is validated (the tool gets the real value), a token no map knows is a decline (the pause staysPending), ablockWithRefusalvalue in them throwsPiiRefusedError. -
DecidedBy.Code—implements ReplyInterpreterHandler<Self>:interpret(input: InputOf<Self>)returns its outcome (#267 S124 b), sync or as a promise —ReplyInterpretation.understood(decision)(OutputOf<Self>, validated like any decision) orReplyInterpretation.notUnderstood({ reason })for a reply it cannot read; a plain object does not compile (the pattern ofChannelOutcomeandJudgeVerdict). Typed by the generated.agboss/types.d.ts;depslike any component, created by the application's container. Athrowis for an unexpected crash only: it is unreadable too, the thrown error itscause. -
Either kind reads in the pause's execution frame: an injected
ExecutionContextreads the pause's execution — itstenant,ownerId, the execution's own fields — and the waiting step (step.nodeId,step.callId, its idempotency key), so the provider's rate limits see the tenant. -
The pause survives a bad reply: interpretation runs before the pause is closed. A reply that
cannot be read as a valid answer — a code interpreter's
notUnderstood, a model that gave no answer, an answer invalid for the role, an interpreter that crashed — throwsUninterpretableReplyError(pause.uninterpretable-reply;reason— the interpreter's own, or the error's message — andcausewhen an error is behind it), and the pause staysPending: ask for a clearer answer, or answer with adecision. Areplyfor a role withoutreplyInterpreterisReplyInterpreterMissingError(pause.no-reply-interpreter), the pause untouched. - Assembly checks, all at once with the channel rules:
[name.kind-suffix](-reply-interpreter),[reply-interpreter.not-an-interpreter](areplyInterpreterclass without@ReplyInterpreter),[reply-interpreter.no-interpret-method],[channel.shared-interpreter](one class in two roles).
Both new forms side by side — a decision model for a deposit return, and an owner's buttons read by code:
// approve / refuse on a decision model: one Decision.noul("approved"), no reason, no overrideArguments
@ReplyInterpreter({
name: "deposit-reply-interpreter",
description: "Reads whether a clerk's reply approves a deposit return",
decidedBy: DecidedBy.Model,
model: "typesafe/jev-1.13",
prompt: "The clerk answered a deposit return.",
})
export class DepositReplyInterpreter {}
@ChannelRole({
name: "deposit-approval-channel",
description: "A clerk approves deposit returns",
approves: DepositReturnDto, // an approval role: a decision model may read its replies
answeredBy: PauseAnswerer.Channel,
replyInterpreter: DepositReplyInterpreter,
})
export abstract class DepositApprovalChannel extends ChannelOf<DepositApprovalChannel> {}
// by code: the chat app sends a button's value back — {"approved":true}; anything else is not understood
@ReplyInterpreter({
name: "expense-reply-interpreter",
description: "Reads an expense button's value",
decidedBy: DecidedBy.Code,
})
export class ExpenseReplyInterpreter implements ReplyInterpreterHandler<ExpenseReplyInterpreter> {
interpret(
input: InputOf<ExpenseReplyInterpreter>,
): ReplyInterpretation<OutputOf<ExpenseReplyInterpreter>> {
const value: unknown = JSON.parse(input.reply); // a text that is no JSON: a crash
if (typeof value !== "object" || value === null || !("approved" in value))
return ReplyInterpretation.notUnderstood({ reason: `not an expense button: ${input.reply}` });
return ReplyInterpretation.understood({ approved: value.approved === true });
}
}
@ChannelRole({
name: "expense-approval-channel",
description: "The thread's owner confirms an expense with two buttons",
approves: ExpenseDto,
answeredBy: PauseAnswerer.ThreadOwner,
replyInterpreter: ExpenseReplyInterpreter,
})
export abstract class ExpenseApprovalChannel extends ChannelOf<ExpenseApprovalChannel> {}
@Channel({ name: "desk-deposit-approval-channel", description: "Asks the clerk on duty" })
export class DeskDepositApprovalChannel extends DepositApprovalChannel {
send(_ask: ChannelAsk<DepositApprovalChannel>): Promise<ChannelOutcome<DepositApprovalChannel>> {
return Promise.resolve(ChannelOutcome.awaitReply()); // posts the question, keyed by pauseId
}
}
@Channel({ name: "button-expense-approval-channel", description: "Shows the owner two buttons" })
export class ButtonExpenseApprovalChannel extends ExpenseApprovalChannel {
send(_ask: ChannelAsk<ExpenseApprovalChannel>): Promise<ChannelOutcome<ExpenseApprovalChannel>> {
return Promise.resolve(ChannelOutcome.awaitReply());
}
}
@Tool({
name: "return-deposit-tool",
description: "Returns a deposit",
input: DepositReturnDto,
output: DeskDoneDto,
channel: DepositApprovalChannel,
})
export class ReturnDepositTool implements ToolHandler<DepositReturnDto, DeskDoneDto> {
run({ leaseId }: DepositReturnDto): Promise<DeskDoneDto> {
return Promise.resolve({ status: `returned the deposit of ${leaseId}` });
}
}
@Tool({
name: "book-expense-tool",
description: "Books an expense",
input: ExpenseDto,
output: DeskDoneDto,
channel: ExpenseApprovalChannel,
})
export class BookExpenseTool implements ToolHandler<ExpenseDto, DeskDoneDto> {
run({ purpose }: ExpenseDto): Promise<DeskDoneDto> {
return Promise.resolve({ status: `booked ${purpose}` });
}
}
@Agent({
name: "office-agent",
description: "Returns deposits and books expenses",
prompt: "Do what is asked.",
model: "moonshotai/kimi-k2.6",
tools: [ReturnDepositTool, BookExpenseTool],
})
export class OfficeAgent {}
@WorkflowStart({ name: "office-workflow-start", description: "A request", input: WorkflowStartTextDto })
export class OfficeWorkflowStart {}
@WorkflowFinish({ name: "office-workflow-finish", description: "The answer", output: WorkflowFinishTextDto })
export class OfficeWorkflowFinish {}
@Workflow({
name: "office-workflow",
version: "1.0.0",
flow: [
start(OfficeWorkflowStart)
.returns(WorkflowStartTextDto)
.toAgent(OfficeAgent)
.toFinish(OfficeWorkflowFinish),
],
})
export class OfficeWorkflow {}@ChannelRole({ …, answeredBy }) is required (no default): a forgotten value must not let a customer
approve their own refund.
answeredBy |
resume takes |
Its pauseId
|
|---|---|---|
PauseAnswerer.ThreadOwner |
the thread's ownerId (another → ThreadOwnerError) |
listed in result.pending
|
PauseAnswerer.Channel |
answeredBy: "manager-17" — non-empty |
only in the channel's ask.pauseId
|
The channel's side authenticates who answers a Channel pause (Slack, a ticket system, an admin
panel already knows the manager); the framework adds no permission model of its own — it checks that
answeredBy is given and records it on the pause and in onExecutionResume (event.answeredBy).
pauseId is the right to answer: a cryptographically random 128-bit token made once per pause,
before the first send — a retried send gets the same one, so it is safe as the idempotency key
towards Slack, mail or a ticket system — and result.pending and onExecutionPause leave it out of a
Channel pause, so the token never leaves the channel's server side. A ThreadOwner pause answered
with answeredBy, or a Channel pause with ownerId (or without answeredBy), is
PauseAnswererError (pause.wrong-answerer); the pause keeps waiting. A concrete @Channel a tool
names as is (TerminalUserChannel) is answered by the thread's owner. The resumed pass runs as the
thread's owner (executionContext.ownerId), whoever answered.
Every pause is a record of the application's PausedExecutionRepository
(App → infrastructure, its SQLite implementation SqlitePausedExecutionRepository), keyed
by the waiting step's idempotency key, so a step re-entered — after a sibling's answer, or a retry
after a crash — finds the same pauseId:
PauseStatus |
Means |
|---|---|
Pending |
waiting; isSent once its channel's send returned |
Resolved |
a resume recorded its decision (atomically, from Pending only); the holder takes it |
Taken |
its step took the decision and went on — or its channel decided at once |
Cancelled |
withdrawn: a sibling failed under FailFast, its launch's quorum was decided, the thread was cancelled, or the tree ended |
An answer to anything but Pending → NotPausedError (run.not-paused): a double click, a late
answer to a withdrawn question. Each record also says how it was resolved (PauseResolution), with
its answer (answer: PauseAnswer — the decision, who answered, what reading a reply cost) and when
(askedAt, resolvedAt, on the application's Clock):
PauseResolution |
How | Status |
|---|---|---|
Decided |
its channel answered ChannelOutcome.decided(…) — the call never paused |
Taken |
Replied |
awaitReply(), then app.resume (a decision or a reply) |
Resolved → Taken
|
Withdrawn |
a FailFast sibling failed, or its launch's quorum was decided, before an answer |
Cancelled |
Cancelled |
app.cancel, or its tree ended before an answer |
Cancelled |
Retention (#267 S107): a resolved record (Taken, Cancelled) is kept
@Application({ pauseRecordRetention }) after it was resolved — default hours(24) — and then
removed by the recovery sweep (every 30 s, on the Clock) through
PausedExecutionRepository.deleteResolvedBefore(cutoff). Within the window a late answer gets
NotPausedError; after it the id is unknown — UnknownPauseError. A Pending pause (and a
Resolved one no pass took yet) is never removed, however old.
{ record: true } on execute / resume returns the execution record
(Observability → the execution record); its pauses are the audit trail — every
resolved pause of the tree, in the order they were asked:
| Field | What it is |
|---|---|
pauseId |
its id — undefined for a PauseAnswerer.Channel pause (only its channel has it) |
executionId |
the execution it waited in: the root's, or a child's (#267 S124) |
step |
the waiting step: agent (its ComponentRecord), node, visit, copy, item, tool call, the loop's toolLoopIteration and validationAttempts (StepRecord, #268) |
role, answeredBy
|
the channel asked and who may answer |
responder |
the ownerId / answeredBy of app.resume; undefined when decided at once, withdrawn or cancelled |
resolution |
PauseResolution |
askedAt, resolvedAt
|
ISO 8601, the application's Clock
|
decision |
what was decided, masked by the application's PII policies; undefined when withdrawn or cancelled |
The list is the permanent history: each entry is written when its pause is resolved — answered,
decided at once, withdrawn or cancelled — into the execution records
(ExecutionRecordRepository.savePauseAnswer(tenantId, rootExecutionId, answer), once per pause), and
read back with findPauseAnswersByTenantIdAndRootExecutionId. The pause records are operational
state and are never read for it: the retention above removes a pause record, never its entry, so an
execution paused for days still lists every pause it was asked.
Each child's own pauses (#267 S124 c): a child execution's record — in result.children and
result.record.children alike (Workflow as a step) — lists the pauses its own
steps asked as ChildExecutionRecord.pauses (the entries whose executionId is the child's, a
grandchild's in its own record), masked like the execution record; the root's record.pauses keeps
the whole tree's. A per-item launch whose children each pause gives each child's record its own
entry (its step.itemIndex the child's item).
FailFast drops paused siblings (#273 S21): a branch that fails cancels its launch, and its paused
siblings' pauses are withdrawn — the tree goes on to the join (its .onJoinError(…), else the
execution fails with the branch's error) instead of waiting for answers nobody needs. The join
runs in the pass that takes the withdrawal, and it still gets the error object the branch threw:
.onJoinError(E, …) matches by class and cause chain as always — .onJoinError(PaymentDeclinedError, …) and .onJoinError(StepFailedError, …) both take an agent that failed with a domain error, and
without a handler the execution rejects with that same AgentFailedError. A channel whose send
throws (not cancelled) fails the asking agent's step: AgentFailedError, the channel's error as its
cause — never a tool error the model reads. A join's
deadline counts elapsed time on the application's Clock, pauses included (Parallel).
A pause inside a child is its branch's (#337): a pause records the launch branches it waits
under — its own step and every step that launched one of its executions — so a FailFast failure
withdraws a sibling child's pause under .toParallelWorkflows(…) too, at any depth, and the
execution fails (or takes its .onJoinError(…)) in the same call instead of resting paused.
A decided quorum drops its waiting branches (#337, Parallel → Quorums):
once a .joinQuorum(…) / .joinQuorumGroups(…) is decided — max counted, a group won, the
deadline passed, it can no longer be reached, or it is found decided when a resume or a recovery
rebuilds it — every pending pause of the launch's unfinished branches is withdrawn
(PauseResolution.Withdrawn): the join runs in the same call, result.pending is empty and a late
answer gets NotPausedError. A deadline that passes while the tree rests (every branch waiting,
nothing running) withdraws nothing until the tree moves again: the next pass — a resume, a recovery
— finds the quorum decided, cancels the unfinished branches and withdraws their pauses then.
A channel that posted a question somewhere — buttons in Slack, a link in a mail — is told when its
pause closes without an answer, so it can take the question back instead of leaving buttons that
lead to NotPausedError. withdraw(withdrawal) is the pair of send: a no-op on ChannelOf (a
role's implementation writes override), optional on a concrete @Channel (ChannelHandler).
import {
Channel,
ChannelOf,
ChannelOutcome,
ChannelRole,
ExecutionContext,
PauseAnswerer,
PauseResolution,
type ChannelAsk,
type ChannelWithdrawal,
} from "agboss";
@ChannelRole({
name: "refund-approval-channel",
description: "Approves a refund",
approves: RefundRequest,
answeredBy: PauseAnswerer.Channel,
})
export abstract class RefundApprovalChannel extends ChannelOf<RefundApprovalChannel> {}
@Channel({
name: "slack-refund-approval-channel",
description: "Asks a manager in Slack",
deps: [SlackClient, ExecutionContext],
})
export class SlackRefundApprovalChannel extends RefundApprovalChannel {
constructor(
private readonly slackClient: SlackClient,
private readonly executionContext: ExecutionContext,
) {
super();
}
async send(
ask: ChannelAsk<RefundApprovalChannel>,
): Promise<ChannelOutcome<RefundApprovalChannel>> {
const { orderId } = ask.value.arguments;
const tenantId = this.executionContext.tenant.tenantId;
await this.slackClient.postApproval(ask.pauseId, `Refund order ${orderId} (${tenantId})?`);
return ChannelOutcome.awaitReply();
}
// called once when a pause this channel sent is withdrawn or cancelled before an answer
override async withdraw(withdrawal: ChannelWithdrawal<RefundApprovalChannel>): Promise<void> {
const { orderId } = withdrawal.value.arguments;
const why = withdrawal.resolution === PauseResolution.Withdrawn ? "no longer needed" : "cancelled";
await this.slackClient.closeApproval(withdrawal.pauseId, `Refund of order ${orderId}: ${why}`);
}
}-
What it gets —
ChannelWithdrawal<Role>: thepauseIdandvalueit was sent, andresolution: PauseCancellation—PauseResolution.Withdrawn(aFailFastsibling failed, the launch's quorum was decided) orPauseResolution.Cancelled(app.cancel, the tree ended). -
Once, by whoever closed the pause — the closing is one atomic
Pending → Cancelledchange (cancelByTenantIdAndPauseId), so of two instances (or a cancel racing a join) exactly one tells the channel. A pause closed while itssendwas still returning is told by its own step, oncesendreturned. Never for a pause its channel decided at once (ChannelOutcome.decided) or never sent (itssendthrew). -
Where it runs — in the pause's execution frame (its
ExecutionContext: tenant, the project's fields, the thread's owner, the waiting step), under its tool'stimeouton the application'sClock:executionContext.signalaborts at the timeout and the call is cut off. -
Best-effort — a
withdrawthat throws or runs past its timeout is one warning in the log,channel.withdraw-failed(LogLevel.Warn, theexecutionId/tenantId), and nothing else: the join,.onError/.onJoinErrorandapp.cancelgo on as if it had returned. - Tests:
mockOf(SlackRefundApprovalChannel).withdrawrecords the calls (toHaveBeenCalledTimes(1)); a contract that resolves to a mocked implementation (a role, anyextends) gets the mock too.
A resume has two phases (Q58): 1. record the decision — after the credential check and the
validation, the pause goes from Pending to Resolved atomically; a second resume of the same
pauseId gets NotPausedError and moves nothing. 2. move the tree — the call takes the tree's
lease (Several instances) and continues with every
resolved pause of the tree in one pass (one LangGraph Command({ resume }) mapping each pause's
interrupt id); before it lets the tree rest paused it takes the pauses resolved meanwhile (the
drain), so no recorded decision waits for another webhook. A call that finds the lease held waits
until the holder ends its pass and returns the tree's current ExecutionOutput, its decision taken;
if the holder's lease expires meanwhile, the waiting call takes it and goes on. A reply that lands
while the channel's send is still returning (a fast webhook) is taken by the step at once — the
call does not pause at all.
A tree whose holder died with nobody waiting is picked up by the recovery of interrupted executions, which takes the resolved pauses first.
app.resume(…) in a test answers like a webhook: a ThreadOwner pause by
result.pending[0].pauseId, a Channel pause by the pauseId your test implementation of the role
was sent. AutoApproveChannel / AutoRejectChannel named on a tool decide at once (no pause), and
mockOf(Implementation).send.mockResolvedValue(ChannelOutcome.decided({ approved: true })) scripts a
role's implementation (Testing). testWith(App, { instances: 2 }) answers two pauses of
one tree from two instances at once. A model reply interpreter is scripted by what it reads —
mockLlm(RefundReplyInterpreter).when({ reply: "ok, but only half" }).thenReturn(replyWith(ToolCallApprovalDecisionDto, { approved: true, … })),
one on a decision model by its yes / no — mockLlm(DepositReplyInterpreter).thenReturn(decideWith({ approved: 0.9 }))
(#267 S124) — and a code one replaced with
mockOf(Interpreter).interpret.mockReturnValue(ReplyInterpretation.understood({ approved: true }))
(mockLlm of a code interpreter is a setup error: it calls no model). Retention is tested with clock.advance(…): the sweep runs on the
test's clock.
Choosing a channel implementation per environment (a terminal in dev, Slack in prod) is a documented limit: one implementation per application.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback — declared compensation
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Naming
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)