Approve what matters. Stop approving what doesn't.
A deployment running all-side-effecting asks the human before every write, which is correct and quickly becomes a reflex: the operator approves the same safe draft-save forty times a day and stops reading the prompt. The fix is not a longer allowlist. It is to let a tool declare what it does, turn that into one computed danger level, and let the deployment pick a mode. By default composition only narrows, so no mode can ever widen past a rule or a host decision — though a host can opt a hook into widening the baseline (see below). A deployment that sets nothing behaves exactly as it does today.
The risk vector
ToolRisk is six tri-state axes. The first one is the mutating field that already shipped; the other five qualify it. Every axis follows the rule mutating already used: undefined means unknown, and unknown is treated as unsafe. So an unannotated codebase is safe by construction and annotating is a pure improvement, never a new obligation.
| Axis | Question it answers | Relevant when |
|---|---|---|
| mutating | does it have a side effect at all? | always, it is the root |
| reversible | can an operator undo it? | mutating: true |
| destructive | is data irrecoverably lost? | mutating: true |
| egress | does data leave the deployment? | always, reads included |
| scope | blast radius: single / collection / deployment | mutating: true |
| live | do end users see it now, or is it a draft? | mutating: true |
// The vector is FLAT on the spec, beside the `mutating` field you already have.
// Every axis is tri-state. `undefined` means UNKNOWN, and unknown is treated as UNSAFE.
export const draftPost = defineTool({
name: "draft_post",
description: "Save a draft. Never publishes.",
inputSchema: z.object({ id: z.string(), body: z.string() }),
mutating: true, // it writes
reversible: true, // drafts are versioned; an operator can restore the previous one
destructive: false, // nothing is lost
egress: false, // the write stays inside the deployment
scope: "single", // one record
live: false, // a draft, not what readers see
execute: async (input, ctx) => { /* ... */ },
});
toolDanger(draftPost.risk); // 1 (low) -> auto-allowed at auto's default thresholdThe danger matrix
toolDanger(risk) is pure, total, and the only thing a mode ever compares against a threshold. It is evaluated top-down; an exhaustive test over the whole tri-state space asserts every combination lands on exactly one row.
| Level | Condition |
|---|---|
| 3 critical | mutating is undefined, or it is true with a gap: destructive !== false, or any of reversible / egress / scope / live left undeclared |
| 2 high | a fully-declared, non-destructive mutation that is nonetheless imperfect (irreversible, or egress, or wider than one record, or live), or mutating: false with egress undeclared |
| 1 low | a fully-declared mutation that is reversible, non-destructive, no egress, scope: "single" and not live. Or a declared-egress read (mutating: false, egress: true), e.g. a web search |
| 0 safe | mutating: false and egress: false |
The relevance rule. reversible, destructive, scope and live qualify a mutation, so they are not consulted at all for a read. For mutating: false the only relevant further axis is egress, because a read can still exfiltrate its arguments. That is why a bare { mutating: false } is danger 2, not 0: one unknown relevant axis is enough to ask. Declaring egress is what buys a read its 0.
Three modes and one threshold
SwarmConfig.approval is opt-in and layers over the toolApproval floor, never instead of it.
| Mode | What it does |
|---|---|
| manual | the legacy decision, verbatim, for both legacy policies, plus protectedTools. This is the behaviour-preserving mode. |
| auto | auto-allow every tool at or below the effective threshold; everything above it asks. |
| permissive | auto, plus it may downgrade an MCP origin-default gate (never an author's explicit needsApproval: true) within the same single threshold. |
defineSwarm({
// ...
toolApproval: { mode: "all-side-effecting" }, // the floor. Unchanged, non-removable.
approval: {
mode: "auto",
autoMaxDanger: 1, // default 1
floor: 3, // danger >= floor is NEVER auto-allowed. default 3
protectedTools: ["publish_post", "delete_page"],
},
});
// One derived number decides every auto-allow in the whole system:
effectiveThreshold({ mode: "auto" }); // 1 (min(1, 3 - 1))
effectiveThreshold({ mode: "auto", autoMaxDanger: 2 }); // 2
effectiveThreshold({ mode: "auto", autoMaxDanger: 2, floor: 2 }); // 1 (the floor binds auto too)
effectiveThreshold({ mode: "permissive", floor: 0 }); // -1 (nothing is ever auto-allowed)There is one threshold in the whole system: effectiveThreshold(cfg) = min(autoMaxDanger ?? 1, (floor ?? 3) - 1). Every allow-capable comparison is danger <= effectiveThreshold(cfg), so floor binds auto too, not only permissive. Note the direction: lowering the floor narrows. floor: 0 returns -1, which no danger level satisfies, so the mode is fully inert: legal, and useful as a kill switch.
One deliberate tightening to know about before you flip to auto: it is stricter than legacy { mode: "flag" } for unannotated tools. flag allows everything without the flag; auto asks on a danger-3 tool, and a tool that declares nothing is danger 3. Annotate first, then flip.
Four layers, and what can widen the baseline
A mode is not a new enforcement path. It is a better seed for the fold that already existed.
| Layer | What | Can it widen? |
|---|---|---|
| 0 | resolveToolBaseline (the mode) | yes, the only stage that can |
| 1 | your preToolCall hook | no by default; opt-in via hostMayWiden |
| 2 | code enforce tool rules | no |
| 3 | dynamic published / skill-borne rules | no |
Layers 1–3 fold with mostRestrictiveTool and foldDynamicToolRules, unchanged, and RuleAction.do has no allow at all. So a mode can never punch through a rule or through your hook, whatever it is configured to do.
One deliberate exception: a host hook may be allowed to widen. Layer 1 is the one place besides the mode that can now relax a decision, and only when you ask for it. Your preToolCall hook is always composed above the resolved baseline — it previously ran unbacked when a deployment set neither an approval mode nor an enforce rule, so an allow it returned could slip a call past the floor. By default the composed hook is narrow-only: a host allow against a baseline ask is tightened back to ask. Set toolApproval.hostMayWiden: true (in @nightowlsdev/hooks) and the hook may go the other way — relax a baseline ask to allow (for example keyed on the tool's risk vector) as well as tighten. Either way an enforce rule still wins last, and on a bare ask tie the host's arg-aware reason is now kept instead of the stock string.
The failure that costs you a day is the silent one: one broad when: { tool: "*" }, do: "ask" enforce rule makes auto completely inert, and nothing tells you. So every tightening transition now names itself, in a structured field and inside the reason string:
// A single broad enforce rule can make a mode completely inert. The decision says so.
{
action: "ask",
reason: "publishing needs a human [overridden by: prebuilt-builder-approval-floor]",
overriddenBy: "prebuilt-builder-approval-floor",
}Every transition stamps, not just the first: on an allow → ask → deny chain the final decision's overriddenBy names the most restrictive contributor while the reason preserves the whole chain. The fold functions themselves are untouched; the two composition boundaries wrap their result.
Annotating your tools
If you already gate tools by hand, you have probably written something close to this already, and the shape of it is the right instinct:
// The host pattern (a real adopter's, before this shipped): one pure resolver over your own
// tool table, returning THREE states rather than two.
function resolveMutating(name: string): boolean | undefined {
if (APPROVAL_TOOLS.has(name)) return true; // it needs approval, so it certainly mutates
if (READ_TOOLS.has(name)) return false; // an explicitly listed read
return undefined; // unknown, and unknown stays unknown
}Two things in that snippet are exactly right, and the framework agrees with both.
The three-way return. Not boolean: boolean | undefined. An unlisted tool is unknown, and saying so is more useful than guessing, because unknown resolves to unsafe on its own. That is the tri-state, one axis at a time.
Refusing to infer read-only from needsApproval. Draft-safe writes are approval-exempt and still mutating, so needsApproval === false does not mean read-only. The two are independent here too: needsApproval is a gate, mutating describes behaviour, and nothing derives one from the other. An author's explicit needsApproval: true is never downgradable by any mode, at any danger level.
What the vector adds is the other five axes and a place to put them. Instead of a resolver keyed by name in your host, the answer lives on the tool that knows it, travels with it into the catalog and the approval card, and reduces to one number the gate can compare. Migrating is mechanical: your READ_TOOLS set becomes mutating: false, egress: false on each of those tools (danger 0), and your APPROVAL_TOOLS set keeps its explicit needsApproval: true, which no mode touches. The interesting work is the tools in neither set: the draft-safe writes, which is where a level 1 buys back a dozen approvals a day.
Annotate truthfully, and leave undeclared what you genuinely cannot know. An axis you guess is worse than an axis you omit, because omitting it costs one approval prompt while guessing it wrong auto-allows the thing you were guessing about. The first-party tools follow their own rule here: search_knowledge and graph_search read your own store, but they embed the caller's query first, and only you know whether that embedder is local or hosted. So both ship with egress undeclared (danger 2, it asks) and expose it as an option: false for a local embedder buys danger 0, true for a hosted one is honest and still auto-allowed at the default threshold. The gate believes an annotation; it has to be true.
Recipe 1: make your hook deny-only
Most host preToolCall hooks exist for one narrow concern (a rate limit, an org policy check, a "needs you" queue) and then carry a second job they never wanted: re-applying the approval floor, because setting a hook made the dispatcher skip its own policy.
// BEFORE. The dispatcher returns a configured hook's decision VERBATIM, so this hook had to
// re-run the policy itself. Forget the last line and "all-side-effecting" is silently deleted
// for every tool in the deployment.
preToolCall: async (ev) => {
if (!ev.tenantId) return toolPolicyDecision(ev, TOOL_APPROVAL);
const rl = await abuse.hit("tool:" + ev.tenantId, TOOL_RATE, nowSec());
if (!rl.allow) return deny("rate limit: retry in " + rl.resetSec + "s");
return toolPolicyDecision(ev, TOOL_APPROVAL); // mandatory, and easy to get wrong
},Setting approval widens the engine's wrap condition, so your hook is folded above the resolved baseline instead of replacing it. Delete the re-application:
// AFTER. Deny-only. Because `approval` is set, the engine folds this hook ABOVE the resolved
// baseline (mostRestrictiveTool), so `allow` here means "the rate limiter has no objection".
// The baseline's `ask` still stands. Re-applying the floor is now not just unnecessary, it is
// wrong: it hides which layer actually decided in the `overriddenBy` diagnostic.
preToolCall: async (ev) => {
if (!ev.tenantId) return ALLOW;
const rl = await abuse.hit("tool:" + ev.tenantId, TOOL_RATE, nowSec());
return rl.allow ? ALLOW : deny("rate limit: too many tool calls, retry in " + rl.resetSec + "s");
},
// ...and, in the same defineSwarm call:
approval: { mode: "manual", protectedTools: ["schedule_post", "send_reply"] },This is a deliberate, opt-in tightening and it is the point of the migration: before, a hook returning allow bypassed the floor; after, it cannot. Note that protectedTools is honoured in manual too, so listing the tools that must always ask is worth doing on day one. It is a no-op under all-side-effecting, and it is the declaration that survives the day someone flips the mode to auto.
Opting in also routes the built-in tools (scratchpad_write, recall_lane, get_page_context) through the same gate, which they previously bypassed entirely. One named exemption: ask is never gate-wrapped. It is the human-approval channel, and gating it would stack a second suspension on top of the question. floor: 0 and protectedTools: ["ask"] simply do not apply to it.
Recipe 2: the arg-dependent case, per call
Some calls are safe because of how they were called, not because of what the tool is: the same publish tool passed dryRun: true is a rehearsal. The engine cannot know a tool's preview semantics, so that judgment stays host-side, but it belongs in hooks.resolveApprovalMode, not in gate logic.
// The arg-dependent case. Note the polarity, because it is the whole lesson.
//
// The instinct is "demote dryRun === true to allow". That is a WIDENING, and no per-call resolver
// can widen: layer 0 computes the deployment decision AND this one and folds them
// most-restrictive-wins. The same intent is expressed by inverting it: the permission lives in
// the DEPLOYMENT config, and the resolver takes it away from the calls that are not rehearsals.
function dryRunArg(args: unknown): boolean | undefined {
if (typeof args !== "object" || args === null) return undefined;
const v = (args as Record<string, unknown>).dryRun;
return typeof v === "boolean" ? v : undefined; // a string "true" is not a rehearsal
}
export function resolveApprovalMode(ev: ToolPreCallEvent): ApprovalModeConfig {
// no boolean dryRun in the args -> this tool has no rehearsal mode -> contribute nothing.
// dryRun: true -> a rehearsal -> contribute nothing; it keeps what the deployment granted.
if (dryRunArg(ev.args) !== false) return APPROVAL;
// dryRun: false -> the LIVE call -> narrow. protectedTools is the one knob that binds EVERY
// mode, manual included, so the live call can never be auto-allowed by any mode adopted later.
return { ...APPROVAL, protectedTools: [...PROTECTED_TOOLS, ev.toolName] };
}
// wired beside preToolCall:
hooks: defineHook({ preToolCall, resolveApprovalMode })The resolver runs only on the async gate path. Layer 0 computes the deployment decision and the per-call decision and folds them with mostRestrictiveTool, so narrow-only holds for any config the resolver returns, including the one a config-ranking design gets wrong: a per-call drop to manual under legacy flag would allow an unflagged danger-3 tool that auto asks on. Numeric sanity clamps run too (autoMaxDanger and floor are clamped down to the deployment's, protectedTools is a union), but the fold is the guarantee. A resolver that throws contributes nothing and the deployment decision stands.
One honest consequence: swarm.tool_call.needsApproval reflects the deployment-resolved baseline, because the badge is computed on a sync path that never awaits your resolver. A per-call narrowing can therefore tighten a call the badge showed as ungated. That is documented rather than hidden; if it matters to your UI, keep the arg-dependent judgment coarse enough to state at the deployment.
Third-party hints escalate only
An MCP server can attach annotations to its tools (readOnlyHint, destructiveHint, idempotentHint). We read them, and we clamp them at the materialization site, before anything can compute a danger from them.
| Hint | Candidate vector | Outcome |
|---|---|---|
| readOnlyHint: true | { mutating: false } → danger 2 | rejected: below the unannotated floor of 3, so the tool keeps danger 3 |
| destructiveHint: true | { mutating: true, destructive: true } → danger 3 | adopted, and it raises any lower declared vector |
| both | the unsafe reading | a contradiction is resolved against the server, not for it |
The clamp never touches needsApproval: an MCP tool keeps its origin: "mcp" approval default, so the gate still sees needsApprovalSource: "origin-default" is still downgradable by permissive alone, and only within its own threshold, with the danger the hint did not change. In short: a remote server can make itself look more dangerous, never less.
Byte-identical when you set nothing
Omit approval and nothing about your deployment changes. Layer 0 is toolPolicyDecision(ev, policy) verbatim, the closure stays synchronous so no extra async hop is introduced, the wrap condition stays exactly what it was so no host is newly routed through the composed path, and the built-ins stay ungated. The shipped hook and built-in test suites pass unmodified, which is the actual guarantee. Modifying one of them to make a mode work would have been a design failure, not a test update.
Set it and you take two deliberate tightenings, both described above: your hook folds above the baseline rather than replacing it, and built-ins run through the gate. Nothing loosens on its own. Reaching for { mode: "manual" } first is a real migration step, not a placeholder: it fixes the composition without moving a single decision.
API reference
- ToolRisk, the six tri-state axes. Declared FLAT on ToolSpec and ClientToolSpec, collected onto the materialized tool as SwarmTool.risk.
- toolDanger(risk: ToolRisk): DangerLevel, pure, total, never throws. toolDanger({}) === 3.
- effectiveThreshold(cfg: ApprovalModeConfig): number, the one derived number. Returns -1 for floor: 0.
- SwarmConfig.approval?: ApprovalModeConfig, the deployment mode. Omit for byte-identical legacy behaviour.
- SwarmHooks.resolveApprovalMode?(ev): ApprovalModeConfig | Promise<...>, the per-call narrowing seam. Gate path only; a throw contributes nothing.
- ToolPreCallEvent.danger / .needsApprovalSource / .userId / .threadId, additive provenance stamped at event construction. needsApprovalSource: "explicit" is never downgradable; "origin-default" only by permissive; an absent source fails closed.
- ToolDecision.overriddenBy?: string, the silent-defeat diagnostic, mirrored into reason as [overridden by: <name>].
- EngineCapabilities.governance.approvalModes?: boolean, optional; absent means unsupported. The three native engines set it. Adapter engines report rather than half-apply.
- swarm.question carries toolName + danger, and <AskBox> renders the tool name with a danger badge when they are present.
Client actions. A client tool's server gate is deny-only by design (the ask is client-mediated via confirmClientAction), so the resolved needsApproval and danger ride the swarm.client_action payload instead, on the Mastra engine. The alt engines' client-tool path bypasses the gate today; that parity gap is tracked, not quietly implied.
Approval integrity across suspensions
An approval is a durable suspension: the run parks on a waitpoint and resumes when the human answers. A re-suspended followup — a repeated approval step, a tool re-asked on a later turn, a re-hit cost cap — used to be resumable with a previous suspension's already-completed durable token, which skips the new question entirely. That is an approval bypass, and it is now closed. Core stamps a generationIndex on each question and @nightowlsdev/runner-background folds it into the Trigger idempotency key, so the durable waitpoint is salted per suspension and every ask gets its own token. It is automatic on the Trigger and in-memory backends; there is nothing to configure.
On the default engine, this durable waitpoint IS the native approval — not Mastra's own requireToolApproval. Night Owls captures the human's approval inside its own governance layer (the gate runs as the first line of every tool's execute), so it parks on the durable waitpoint above and resumes across a process restart or a different serving process. Mastra ships a per-call requireToolApproval switch, but it pauses the live turn in memory and is lost on a restart or when the approval lands on another process (serverless / background / load-balanced), so it cannot back a durable HITL run — and it would sit strictly above this gate, never around it. So engine-mastra deliberately does not surface it: the waitpoint supersedes it, works identically on every engine, and (running inside execute) composes per-call on the tool's arguments — which a pre-execute vendor switch cannot.
Closed. The Vercel durable path salts its hook token too now — <followupId>#<round>, the same per-suspension discipline as Trigger's. It parked on the raw, re-ask-STABLE followupId until FR-070 r12, which is the same bypass one backend over; the note that it was “unverified” was itself the defect, so the fix removes the dependency on that assumption rather than resolving it. The driver remains pre-production and smoke-gated.
Arity: one outstanding approval per run segment
A run has at most one approval question outstanding at a time, and that is a property of the engine, not a limit of any particular UI. A suspension ends a run segment: the engine returns at the first tool-call-suspended chunk it sees, parks the run, and emits one swarm.question. So when a model step emits several gated tool calls at once — which every frontier model does, routinely — you are asked about the first one only.
The surplus is queued, not dropped. Nothing is silently lost, nothing is auto-approved, and nothing errors. Answering the first approval runs that one tool and then immediately re-parks the run on the next gated call in the step, as a fresh question with its own followupId, toolCallId and generationIndex. N gated calls in one step means N questions and N resume() calls, strictly in the step's emission order — each on its own segment, each with its own durable waitpoint (that is why §11's per-suspension salt matters here: the surplus approvals are exactly the repeated suspensions it distinguishes).
The model never sees the pauses. The whole step costs one generation regardless of N: when the last approval settles, all N tool results reach the model together, in the single tool message that step was always going to produce. There is no re-prompt, no re-billing, and no approval artifact in the transcript for a later turn to replay.
- You can see the queue coming. Every call in the step emits its own swarm.tool_call — carrying the resolved needsApproval — up front, before the single question. A tray can honestly render “1 of 3”. Only one card is answerable.
- Approve-all must be sequential, and it fails loudly if it is not. The followup record for call k does not exist until the engine parks on call k, and the resume routes gate on findSuspended plus a matching runId/toolCallId. Fire N submits at once and you get one success and N−1 403s — not lost approvals, but not a batch either. Drive each submit off the next arriving swarm.question, never off the swarm.tool_call list.
- The queue is not atomic. Approving the first call runs its side effect before you are shown the second, so rejecting the second afterwards does not roll the first back. Design the operator's mental model around “each approval commits on answer”, not “the batch commits at the end”.
- Ungated siblings are ordered, not exempt. A step's calls execute sequentially in emission order, so an ungated call ordered before the gated one has already run by the time the card appears, and one ordered after it is stalled behind the approval until the human answers. A pending approval holds the whole step, not just its own call.
Batch approvals are the B4 backlog item, sequenced behind §11's Vercel salt follow-up — a batch waitpoint is only safe once every durable path salts per suspension. Until then, one-at-a-time is the contract, and it is a contract rather than an accident: every claim in this section is pinned by packages/core/test/fr069-approval-arity.test.ts, which drives real multi-call steps through ask → resume → ask and asserts the counts, the order, the generation indices, and which side effects ran.
Strict workflows gate the same way. A tool step in a compliance: "strict" workflow runs through the same gate: it parks the run with a kind: "approval" question, and resume() approves or declines it. An approval is a decision about the step, never the step's output — a downstream { $ref: "steps.<id>" } still resolves to the tool's result. Approving runs the body once, on the arguments frozen when the question was asked, after re-consulting the gate (a deny that lands during the pause still blocks); declining fails the step with rejected by approver, routable through the step's own onError so a decline can be a branch rather than a crash — on every arm, including { retry }, where a decline is terminal rather than retried (a retry policy is for faults, not for a human who said no). One gated step, one question; a step re-entered later by an onError: { retry } asks again, and that budget is now persisted across the pause, so retry: N really does cap the attempts. The pause itself is fully durable before the question is emitted — snapshot, followup index and suspended status are all written first, so a background runner that stops at the question still finds a resumable park. Pinned by packages/core/test/fr070-workflow-tool-approval-resume.test.ts and packages/core/test/fr070-gate2.test.ts.
Answering: the resume contract
Asking is half of it. The answer has to reach the exact suspension it was minted for, exactly once, and every other delivery has to be refused in a way a transport can tell apart from a crash. Three rules, and one of them is breaking for anyone who calls the engine directly.
1. An answer carries the round it replies to — and a resume without one now throws. A workflow step's followupId is stable across re-asks (a retried step, a re-hit cap), so the id alone cannot tell round 2's question from round 1's. A strict-workflow park stamps pending.generation — the same number the question carries as generationIndex — and resume() raises FollowupGenerationStale when the park recorded a round and the args omit it. It throws before the answer-once CAS, so a refused resume consumes nothing and the ask stays answerable. A park written by an older core recorded no round, so in-flight runs across the upgrade still resume without one. Scope, precisely: the round is recorded on workflow.pending, which only a strict-workflow park writes. A free-form agent ask records none, so the guard is deliberately inert there and a generationless resume of one is unchanged — a free-form followup is consumed by the answer-once CAS and never re-opens under the same id, so there is no superseded round for a replay to satisfy. Nothing to do for those; everything below is about workflow approvals.
2. Every shipped transport already threads it. @nightowlsdev/react's answer(), the runner-nextjs resume route, runner-background (waitpoint payload → driveLoop), the Vercel workflow body, mcp-server's ask_<agent>, and both out-of-band channels in connectors (the email reply token, the Slack delivery ref). Use one of those and there is nothing to do. Two places are on you: a custom transport, and the overdue-approval sweep. sweepOverdueApprovals ships, but its listOverdue and expire are host-supplied — the package routes the decision, your code does the resume. Update listOverdue to read snapshot.workflow.pending.generation onto each OverdueAsk, and expire to forward ask.generation. If you miss it, the new staleGeneration counter on SweepResult says so — the sweep no longer reports a misconfigured tick as a healthy one.
3. A refused answer is a 409, not a 500 — and it is structural. A followup is answered exactly once: the winner of the compare-and-set drives the run and every other delivery is refused. That is normal — a double-click, a retried webhook, an email reply that raced the UI — so it must not read as a failure. resumeRefusalCode(err) returns one of already-answered, stale-generation, not-resumable, no-match, not-attached, or undefined for a real failure — a marked class, never a message match, so it survives a package boundary. The shipped route answers every refusal — the engine's AND its own pre-engine ones, including an ordinary double-answer — with 409 { error, reason, message, retryable }; the durable runners use isBenignResumeRefusal and return silently rather than failing a run someone else is already driving.
4. One of the five means “try again”, and only one. not-attached is the odd code out. A park becomes VISIBLE (the question is persisted, the row reads suspended) a moment before it becomes ANSWERABLE (the durable runner mints the waitpoint and attaches it). An answer landing in that window used to be acknowledged and then dropped — a 200 for an answer that executed nothing. It is now refused, and refused retryably: nothing is consumed and the identical answer works a moment later. Branch on isRetryableResumeRefusal(err), never on the string. In @nightowlsdev/react it surfaces as resumeRefusal with the open question left intact; in connectors an inbound reply that hits it THROWS rather than ACKing, so the provider redelivers instead of dropping a real human answer.
import {
resumeRefusalCode,
isRetryableResumeRefusal,
type Engine,
type ResumeArgs,
type ResumeRefusalCode,
type StorageAdapter,
type SwarmContext,
type SwarmEvent,
} from "@nightowlsdev/core";
// ── 1. THE ROUND ────────────────────────────────────────────────────────────────────────────────
// An answer is bound to the SUSPENSION it replies to, not just to the followup: a workflow step's
// followupId is STABLE across re-asks, so without the round a re-delivered approval from round N
// would satisfy round N+1 with no human decision.
/** Read it off the question you are answering. */
export function roundOf(q: Extract<SwarmEvent, { type: "swarm.question" }>): number | undefined {
return (q.data as { generationIndex?: number }).generationIndex;
}
/** …or, server-side, off the park itself. */
export async function roundFromPark(
storage: StorageAdapter,
tenantId: string,
runId: string,
): Promise<number | undefined> {
const snap = (await storage.runs.loadSnapshot(tenantId, runId)) as
| { workflow?: { pending?: { generation?: number } } }
| null;
return snap?.workflow?.pending?.generation;
}
// ── 2. THE REFUSAL ──────────────────────────────────────────────────────────────────────────────
// Structural, never a string match: five codes, all meaning "this delivery does not apply" rather
// than "the run failed" — so a transport answers 409, never 500, and a retry loop does not spin.
// Exactly one of them is RETRYABLE; ask the predicate rather than restating the rule.
export async function answerTheAsk(
engine: Engine,
args: ResumeArgs,
ctx: SwarmContext,
): Promise<Response> {
try {
for await (const _event of engine.resume(args, ctx)) {
// stream to your client
}
return Response.json({ ok: true });
} catch (err) {
const code: ResumeRefusalCode | undefined = resumeRefusalCode(err);
if (!code) throw err; // a real failure still propagates
return Response.json(
{
error: "resume refused",
reason: code,
message: err instanceof Error ? err.message : String(err),
retryable: isRetryableResumeRefusal(err),
},
{ status: 409 },
);
}
}
Where to go next
@nightowlsdev/hooks
ToolRisk, toolDanger, effectiveThreshold, resolveToolBaseline. Zero runtime dependencies.
@nightowlsdev/core
SwarmConfig.approval, the risk fields on ToolSpec, and the composition that folds above it.
Client tools
Where the browser-side confirmation seam lives, and why its gate is deny-only.
Building on Night Owls? See the source on GitHub.