Human in the Loop
On this page
A tool-calling agent regularly needs a human for one of two reasons: a
gated tool call needs permission (actauth's own ask decision), or the
model itself is genuinely unsure and calls system_ask_user instead of
guessing. Both go through the exact same live-vs-durable split. This doc
is both halves of that story: the practical "what do I actually need to
build" setup guide, and the design reference underneath it — how
durability, notification, and resumption actually work, so an
ApprovalPayload/TurnCheckpoint/WebhookNotifier mentioned in a code
comment somewhere has one place to look it up. For the wire-level SSE
event protocol, see PROTOCOL.md.
Status: implemented and verified — real webhook deliveries with a checked HMAC signature, a resolved approval resuming the turn through the normal durable session-append path, a chained second pending call inside a resumed turn, a rejected-then-retried edited-args approval, and a two-step durable-question chain, all exercised live end to end, not just unit-tested.
The one decision that determines everything else
Is a human going to be live, watching, for the entire duration — including however long they take to decide?
- Yes (someone's actively chatting with the agent right now) → use
the
http_streamchannel. A human is the approval/question mechanism already, implicitly; you just need a UI that shows them the pending card and lets them respond. - No (a webhook/cron-triggered run, a support ticket that came in at
3am, anything unattended) → use durable approvers/question handlers on
the
httpchannel. Nobody's watching, so the turn has to survive the process exiting — real infrastructure, not just a config flag.
You can mix both per agent: cli/http_stream always get the library's
own live defaults automatically, with nothing to configure, and http
gets its own durable config via AgentConfig.httpNotifier (see below).
agents/customer-service/index.ts does exactly this: live on
cli/http_stream, durable on http when its own webhook env vars are
set.
Path 1: http_stream — live chat
What's already done for you (the server side)
Nothing to configure. adapters/http.ts's handleMessagesStream
unconditionally builds a fresh WebchatApprover per turn and wires
onQuestionPending — a pending approval/question streams as an SSE
event (approval:pending/question:pending) on the exact same
connection the client already has open, no AgentConfig fields, no env
vars, nothing to set up server-side. Nothing can override this for
http_stream either — AgentConfig.httpNotifier only ever matches
channel === 'http', so it's structurally unreachable here.
What you still need to build (the client side)
Something has to consume the SSE stream, recognize those two event types, render a card, and call the resolve routes when a human acts. That's real UI work — not zero-cost, just server-cost-zero. What loopengine gives you toward it, so it's not from scratch:
core/client.ts— framework-agnosticstreamMessage,approveCall,denyCall,answerQuestion: SSE parsing and the REST calls, already correct, reused by everything below.examples/chatbox/react/useLoopChat.ts(and the Vue equivalent) — a reference hook trackingpending: PendingApprovalEvent | PendingQuestionEvent | nullas component state, built onclient.ts. Handles state, not rendering — you still write the actual buttons/input, but you're not reimplementing "how do I know something's pending" from raw SSE frames.web/playground.ts— a complete, working reference UI (appendApprovalCard/appendQuestionCardrender real buttons and a text input, wired to the real REST calls). Usable directly for local dev (npx loopengine dev→http://localhost:8787/playground), or as a template for your own.
The actual request/response shape
npx tsx --env-file-if-exists=.env adapters/http.ts
curl -N -X POST localhost:8787/agents/customer-service/messages/stream \
-H 'content-type: application/json' \
-d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
Watch for an event: approval:pending or event: question:pending
frame; when one arrives, render it and, once the human responds:
curl -X POST localhost:8787/approvals/<id>/approve # or /deny
curl -X POST localhost:8787/questions/<id>/answer -d '{"answer":"..."}'
The original SSE connection just continues once resolved — there's no separate "how does the requester find out" problem, since they never left.
Path 2: http — durable
Real infrastructure, several genuinely separate pieces. None of this is optional plumbing you can skip — durability's whole point (surviving a process restart, a webhook that fires at 3am with nobody watching) needs every piece below.
1. Pick and configure a notifier
Either a deployment-wide default (env vars, adapters/http.ts) — one
target covers both approvals and questions, via one shared
WebhookNotifier instance (core/http-notify-triggers/webhook.ts):
LOOPENGINE_DEFAULT_WEBHOOK_URL=https://...
LOOPENGINE_DEFAULT_WEBHOOK_SECRET=...
Or a per-agent one, which always wins over the deployment-wide default
(agents/<name>/index.ts):
export const config: AgentConfig = {
// ...
httpNotifier: {
channel: 'webhook', // or 'slack' | 'lark' | 'email' | 'database' | 'redis' — see below
config: { webhookUrl: process.env.MY_WEBHOOK_URL!, webhookSecret: process.env.MY_WEBHOOK_SECRET! },
events: ['approval', 'question'],
},
}
AgentConfig.httpNotifier replaces what used to be up to four separate
fields (a channel-keyed approvers.http override, questionHandlers.http,
onRunStart, onRunFinish) with the single shape almost every real
deployment actually wants: one target, receiving whichever of
approvals/questions/lifecycle events it asks for via events. Only
http ever consults it — cli/http_stream keep the library's own live
defaults (ConsoleApprover, CliQuestionHandler, WebchatApprover, WebchatQuestionHandler)
automatically, with nothing to configure. When it's set for http, it
still wins outright over whatever the adapter itself would otherwise
default to (adapters/http.ts's plain /messages route always passes
some options.approver of its own) — the agent's own explicit choice
for that channel beats the deployment-wide default.
All six channels have their own file under core/http-notify-triggers/,
one class per channel covering both approvals and questions:
channel |
Class | Notes |
|---|---|---|
'webhook' |
WebhookNotifier |
Signed HMAC-SHA256 POST — X-Actauth-Signature for an approval, X-Askuser-Signature for a question. |
'slack' |
SlackNotifier |
chat.postMessage with interactive buttons. |
'lark' |
LarkNotifier |
Lark/Feishu's own card API (lower confidence on exact card-schema field names — see that file's own header comment). |
'email' |
EmailNotifier |
A sendEmail callback plus a signed, expiring magic-link token per link. |
'database' |
DatabaseApprover |
Writes a row for a separate worker/dashboard to poll. Approval-only — see below. |
'redis' |
RedisQueueApprover |
Pushes a queue entry. Approval-only. |
resolveHttpNotifier (core/http-notifier.ts) constructs whichever one
channel names — no example code required for the sending side, for any
of them. 'database'/'redis' aren't a notification channel at all —
nothing gets told anything, a human or worker just polls the row/queue
directly — so 'approval' is the only event either ever does anything
with (AgentConfig.ApprovalOnlyHttpNotifierEvent enforces this at the
type level).
2. Pick a notification channel and stand up its receiving side
The notifier's job is just "fire a notification, return a pendingId
immediately" — surfacing it to an actual human, and verifying whatever
comes back really is that human, is a separate server you own (except
for database/redis, which have no receiving side at all). Reference
implementations exist for the four that need one, one file per channel
under examples/notifier-handler/:
| Channel | Sending (built for you) | Receiving (you deploy) |
|---|---|---|
| Generic webhook | httpNotifier's channel: 'webhook' |
examples/notifier-handler/webhook.ts's verifyWebhookNotifier |
| Slack | httpNotifier's channel: 'slack' |
examples/notifier-handler/slack.ts's SlackNotifierHandler |
| Lark/Feishu | httpNotifier's channel: 'lark' |
examples/notifier-handler/lark.ts's handleLarkInteraction |
| Email (magic link) | httpNotifier's channel: 'email' |
examples/notifier-handler/email.ts — imports verifyMagicLink from core/http-notify-triggers/email.ts rather than reimplementing it, since sending and verifying have to share one exact algorithm |
Every one of these four files is receiving-side-only: an HTTP endpoint you still have to deploy yourself (an example, not a library export), regardless of which channel.
3. Resolve, once a human decides
curl -X POST localhost:8787/pending-approvals/<id>/resolve \
-d '{"decision":"approve"}' # or "deny", optionally "editedArgs"
curl -X POST localhost:8787/pending-questions/<id>/answer \
-d '{"answer":"..."}'
Both sit behind LOOPENGINE_ADMIN_AUTH Basic Auth, same as every other
route on that server. editedArgs lets an approver fix the model's
drafted arguments before running the tool, not just accept/reject them
verbatim — see Approve-with-edit below.
4. Telling the original requester it's actually done
The original POST /messages caller already got back a 202 {pending: true} and is long gone by the time a human answers, possibly days
later — a genuinely different party than whoever just approved/answered
in Slack/email/wherever (a customer waiting on a refund isn't the ops
person deciding whether to issue it). Whether this needs solving at all
depends on your use case: if the caller was just a trigger and the value
is in the side effect (a database row changed, a ticket closed), nobody's
waiting on a reply and there's nothing to build. If a real human is
sitting on the other end of that original request — a support thread, an
SMS conversation — they need to hear back somehow.
AgentConfig.onRunStart/onRunFinish exist for exactly this — fire-and-
forget hooks on runAgent()/resumeAgent() themselves (so they fire
for any caller, not just adapters/http.ts's own routes):
onRunStart for visibility into an unattended run actually beginning
(an admin log line, a Slack "processing your request" post — trigger
tells you whether this is a fresh message or a durable resume, so you can
filter to only the case you care about), onRunFinish for the moment a
turn reaches a genuine terminal outcome (never for
pending_approval/pending_question — those are paused, not finished).
Neither ever fires on cli/http_stream — both channels already deliver
the start/finish signal to whoever's waiting synchronously, as part of
that channel's own normal response, so loopengine skips the redundant
copy for you rather than leaving every implementation to filter it out
itself; a caller with no channel set at all (a bespoke script) still
fires either way, since there's no such guarantee to assume for it.
Neither carries contact info itself — that's on you to re-derive from
sessionId/tenant/agent (customer-service's own sessionIdFor
already does this kind of lookup for the email case), the same way none
of this doc's own notifiers know who a human is, only how to reach
whichever channel you configured. A send_sms/send_email tool the
agent calls itself as part of finishing the turn is still the simplest
option if you already have one — onRunFinish is for when you need it
to happen deterministically, not conditional on the model remembering to
call a tool.
Quick comparison
http_stream (live) |
http (durable) |
|
|---|---|---|
| Server-side setup | None — auto-wired | Webhook config + a receiving server you deploy |
| Client/notification UI | Build or reuse client.ts/the chatbox hooks/the playground |
Build or reuse one of the examples/notifier-handler/ channel files |
| Survives a restart | No — one open connection for the whole turn | Yes — that's the entire point |
| Fits unattended/background triggers | No | Yes |
| "Turn finished" notification back to the original requester | Automatic (same connection) | AgentConfig.onRunFinish gives you the hook — reaching the requester is still your own code |
Neither is "better" — they answer different questions about who's present when the agent needs a human. Use both, per channel, on the same agent, if you need to serve both situations.
How durability actually works
Everything above is the "what do I configure" version. This section is the "why does it work this way" one — for when a code comment points here, or you're extending the mechanism itself rather than just wiring it up.
The problem
WebchatApprover and SlackChatApprover (both from actauth) hold
pending approvals in an in-memory Map, with a default 5-minute timeout
that fails closed (auto-denies) if nobody responds — run-agent.ts's
loop awaits the approver directly, so the entire turn is a live,
suspended JavaScript call, inside one process, for as long as the human
takes. That's the right model for genuinely live chat. It's the wrong
model for anything triggered by a business process — a support ticket, a
PR, a lead created in a CRM — where the realistic delay before a human
looks at it is minutes to days, and a 5-minute fail-closed timeout means
the normal case silently auto-denies before anyone sees it, on top of
the suspended turn not surviving a deploy or restart regardless.
Bucket, then execute — never evaluate-and-immediately-act
A naive fix (an approver that throws to signal "pending") is broken:
run-agent.ts evaluates tool_use blocks in a sequential for
loop, in whatever order the model emitted them. A thrown exception
unwinds that loop immediately, so a gated call early in the batch would
stop every later block — including plain auto-allowed ones — from ever
being evaluated, making execution order-dependent on something nobody
should have to reason about.
The real fix: durable pending decisions can't be squeezed through a
Promise<boolean> contract at all — awaiting a promise that only
resolves when a human clicks something two days later means the process
sits blocked for two days, exactly what durability exists to avoid. The
honest fix is a second, explicitly different interface:
interface DurableApprover {
requestDurableApproval(
tool: string, args: Record<string, unknown>, scope: Scope, reason: string,
): { pendingId: string } // fast — creates the pending record and returns; never awaited for the actual decision
}
Gate.evaluate() (in actauth) duck-types its configured approver and
dispatches accordingly — a plain Approver's allow/deny gets handled
exactly as before, and a new 'pending' decision (carrying a pendingId)
goes straight into a pending bucket instead of being resolved inline.
Either way the sequential loop reaches every block regardless of where
the gated one sits — no exceptions, no hanging.
Once every block in a batch has been evaluated this way, three buckets
exist: calls to run now (allow), results to synthesize now (deny),
and calls still waiting (pending) — the first two execute/synthesize
immediately; a pending sibling elsewhere in the batch never blocks
already-decided work.
Worked example
Model requests four tool calls in one turn: lookup_order (t1,
auto-allowed), send_email (t2, auto-allowed), issue_refund (t3, needs
approval), and a fourth gated tool (t4, also needs approval).
Evaluation (order-independent, per above): t1 and t2 land in the
run-now bucket and execute immediately, producing real results. t3 and
t4 both land in the pending bucket, each getting its own pending record —
but both point at the same checkpoint, because the eventual
tool_result message has to answer all four tool_use blocks from this
one batch at once; it can't be split across two messages. The real
shape, core/durable-approvals.ts:
interface TurnCheckpoint {
id: string
sessionId: string
agent: string
tenant: string
resultsSoFar: ModelContentBlock[]
// Keyed by pendingId, not toolUseId — a resolve call only ever names a
// pendingId (what DurableApprover.requestDurableApproval handed out),
// so that's what has to be the lookup key, with everything execution
// needs (toolUseId, tool, args, reason) as the value.
outstanding: Record<string, { toolUseId: string; tool: string; args: Record<string, unknown>; reason: string; kind?: 'approval' | 'question' }>
closed: boolean
}
At the moment the turn pauses: resultsSoFar = [t1's real result, t2's real result] — they already have both a decision and a result, so
they were never "outstanding" in the first place. outstanding =
{t3's pendingId: {...}, t4's pendingId: {...}} — the calls genuinely
still waiting on a human.
Both approved (either order): whichever resolves first appends its
real result and removes itself from outstanding; the turn stays paused
until the second one resolves too. Once outstanding is empty, the
complete four-result message is assembled and pushed durably, and the
loop continues — a fresh model call, now with the full picture.
One denied while the other is still unresolved: denial closes the
checkpoint immediately, regardless of what else is outstanding. If
t4 is denied while t3 hasn't been touched yet, t3 gets a synthesized
skipped result right then (same as a synchronous denial's own "a
denial cancels the whole batch, siblings that hadn't run yet get marked
skipped" behavior) — it can't be left dangling. The message gets pushed
with t1/t2's real results + t4's denial + t3's skip, and the loop stops
with stopReason: 'denied'.
This leaves t3's own pending record pointing at an already-closed
checkpoint. If its approve/deny link gets clicked later — plausible,
since whoever owns that decision may not know a sibling was already
denied — the resolve path treats "my checkpoint is already closed" as a
graceful no-op ({alreadyResolved: true}), never an error and never
a second attempt to execute or push anything.
What's durable, and where
The conversation itself needs no new storage — the assistant's
tool_use-laden message and (once complete) the answering tool_result
message both go through the existing session-store append path
unchanged. TurnCheckpoint is the one new thing that needs real durable
storage, and it doubles as its own pending-decision index — no separate
table needed, since each pendingId in outstanding already is the
lookup key.
Implemented as core/durable-approvals.ts's CheckpointStore,
deliberately mirroring session-store.ts's own shape: a create() plus
a locked read-modify-write (withCheckpoint(pendingId, fn)), backed by
FileCheckpointStore (one JSON file per checkpoint, one small pointer
file per pendingId under .checkpoints/) for local dev or
RedisCheckpointStore (a checkpoint:<id> string + a
checkpoint-pending-index:<pendingId> → checkpoint id pointer) for
multi-instance deployments, chosen by the same REDIS_URL-gated
createCheckpointStore() factory pattern createSessionStore() already
uses.
withCheckpoint(pendingId, fn)'s callback receives the checkpoint (or
undefined for an unknown/already-closed pendingId — the graceful
no-op case) and returns {checkpoint, result}; the store persists
checkpoint (or deletes it, if closed: true) and hands result back
to the caller. adapters/http.ts's resolve/answer routes use this
directly — see End to end below.
Notification: request + raw resolve only
Durability and notification are two separate problems — everything
above only solves the first. Notification isn't a separate concept
bolted on afterward — it's something a DurableApprover implementation
does, as part of handling requestDurableApproval(), the same way
SlackChatApprover posting to Slack today isn't a step bolted onto
requestApproval(), it is requestApproval().
These classes can't own the entire request/resolve round trip the way
a live approver does, though. A live approver's requestApproval() can
own resolution completely because nothing has to survive past one
await — decide() settles the promise, Gate.evaluate() unwraps it,
done. A durable approver's "what happens after a human clicks approve"
is resume the turn from TurnCheckpoint — needs the session store and
the model call, none of which actauth knows about or should, same
reason it stays agnostic of sessions and turns generally. So a durable
sender only owns the request side (create the pending record, fire
the notification) and a raw resolve entrypoint (verify + record a
decision by pendingId) — loopengine supplies the callback that reacts
to a resolved decision and actually resumes the loop. That split is why
actauth's WebhookApprover/WebchatApprover/SlackChatApprover live
in actauth itself (general-purpose, no loopengine concepts), while
system_ask_user's durable senders (core/http-notify-triggers/) live
in loopengine — system_ask_user never goes through actauth's Gate
at all (see core/system-tools/ask_user.ts's own header comment on why
gating it would be circular), so actauth has no reason to know it
exists.
Approve-with-edit
Not every pending decision is binary. Even once a call is correctly
routed to ask, the approver's real intent usually isn't "yes/no" —
it's "yes, but let me fix the wording first." The tool call already
carries the model's drafted args; the approver should be able to edit
them and then approve, not just accept or reject the draft verbatim.
This doesn't touch the checkpoint/bucket-then-execute shape at all — it
only widens the resolve request body, adapters/http.ts's POST /pending-approvals/:pendingId/resolve:
{ decision: 'approve' | 'deny', editedArgs?: Record<string, unknown> }
When editedArgs is present, tool.execute(editedArgs ?? item.args)
runs with those in place of the model's original args. editedArgs is
checked against the tool's own input_schema (a small hand-rolled
required-fields check, not full JSON Schema validation) before it runs —
an invalid edit returns 400 with the checkpoint left completely
untouched, so the same pendingId can be retried with corrected args
rather than being permanently burned by a typo. The tool_result the
model eventually sees reflects what actually happened — the edited
values that were actually sent, never the model's original draft, once
validation passes.
An approval UI (Slack message, magic-link page, admin console) needs to
render pending args as an editable form when the tool's schema allows
it, not just an approve/deny button pair — a UI concern, not a protocol
one, but worth flagging since it's the whole reason args is already
exposed on approval:pending.
Content-conditional gating
The same tool can need different treatment depending on what it's being
asked to do — send_email auto-sends a tracking-info reply but should
pause for a refund reply. This needs no new architecture: actauth's
Gate.evaluate(tool, args, scope) already resolves against a RuleSet
whose rules can carry a when: Condition — {field, op, value},
dot-path-resolved into args, through a fixed op table (eq, ne,
gt, gte, lt, lte, in, contains — no eval, deliberately).
Matching against free-text prose ({field: body, op: contains, value: "refund"}) is a heuristic, not a classification. The robust version
gives the tool schema an explicit enum field the model fills in itself —
the model already knows why it's writing the email, so let it say so
structurally, and gate on that field with eq instead of scanning
prose:
input_schema: {
type: 'object',
properties: {
body: { type: 'string' },
intent: {
type: 'string',
enum: ['tracking_info', 'refund', 'other'],
description: 'Why this email is being sent, so a human reviewer knows whether to check it before it sends.',
},
},
required: ['body', 'intent'],
}
The description is doing the real work — the model classifies based on
what it's told the field means, the same way it "knows" to put
customer-facing prose in body because the tool's own description says
"email the customer." loopengine doesn't validate tool args against
input_schema before executing, so a malformed or unrecognized intent
value doesn't throw — the rule just silently falls through to the
ruleset's default_decision. That makes the fallback the thing that
actually determines safety: write rules positive-match-to-allow,
default-to-ask, so anything that isn't an exact, recognized match —
including a bad value — asks, rather than default-to-allow with a
growing blocklist of risky cases:
rules:
- tool: send_email
when: { field: intent, op: eq, value: tracking_info }
decision: allow
default_decision: ask # anything else — including a bad value — asks
agents/customer-service/tools/send_email.ts and
agents/customer-service/actauth.yml are the real, live version of this
example, not just an illustration.
Durable questions (ask_user)
system_ask_user gets almost exactly the same treatment tool-call
approvals do above, reusing more of it than a fresh design would suggest:
DurableQuestionHandler(core/agent-config.ts) — the question-side sibling ofDurableApprover, same positional-args shape (notifyPendingQuestion(question, options, agent, sessionId): {pendingId}, mirroringrequestDurableApproval(tool, args, scope, reason)).core/http-notify-triggers/webhook.ts'sWebhookNotifierimplements both this andDurableApproverin one class — a signed HMAC webhook POST (headerX-Askuser-Signaturefor a question,X-Actauth-Signaturefor an approval), fire-and-forget either way; so do itsslack.ts/lark.ts/email.tssiblings.WebchatQuestionHandler(core/system-tools/ask_user.ts) — the live sibling: holds thePromiseopen and resolves it directly, no webhook, nopendingIdhanded to a durable resolve route. One deliberate difference fromWebchatApprover:onPendingis a per-call argument torequestQuestion(), not a constructor option, because a question only ever needs one shared registry (unlike approvals, which get a freshWebchatApproverper streamed turn specifically so each one'sonPendingcan target that turn's own SSE connection).CliQuestionHandler(core/system-tools/ask_user.ts) — the cli-channel default: a blocking terminalrl.question(), nothing else.createAskUserTool's ownonPending-presence check picks between the two live handlers (adapters/cli.tspasses no live push callback, so it always lands onCliQuestionHandler;adapters/http.ts's both routes always pass one, so they always land onWebchatQuestionHandler).- One unified pending bucket, not two parallel ones. A gated tool
call and a
system_ask_usercall can land in the same model response — the same "one dangling assistanttool_usemessage can only ever get one completingtool_resultmessage" reasoning from the worked example above applies identically here.run-agent.ts's loop collects both kinds into onepending: PendingItem[]({kind: 'approval' | 'question', toolUseId, tool, args, pendingId, reason}), andRunAgentResult.pending.outstandingcarries both kinds together.stopReasonis'pending_question'only when a batch contains no approval items at all; a mixed batch reports'pending_approval'(an approval-aware caller already has to handle that case, so it needs no new branching for the common pure-approval batch). - Resolution is simpler than an approval's:
POST /pending-questions/:pendingId/answertakes{answer: string}and uses it as the completingtool_resultcontent directly — notool.execute(), noeditedArgs/schema validation, no deny/cascade-skip concept. It shares the exact "outstanding empty → resume viaresumeAgent, else report the remaining count" tail with the approval route. Each route rejects the other kind'spendingIdwith a400naming the correct route. - No live-question override wired through
httpNotifieryet. Only the durable half of a resolvedQuestionHandleris actually wired to anything — the live branch always falls through tocreateAskUserTool's own independent resolution (keyed offonQuestionPending's presence). Fully closing this gap would mean movingsystem_ask_user's live answer-collection inline into the loop, changing today's batching semantics (a pending question is currently deferred until the whole batch is scanned and confirmed denial-free — approvals don't have that property, since a liveaskis already awaited mid-scan) — deliberately deferred, not shipped half-carefully.
End to end
- A message comes in (live chat, or a background trigger calling
runAgent()directly with aDurableApproverasoptions.approver) → the loop evaluates the model's requested tool calls. One hits anaskrule routed to aDurableApprover→Gate.evaluate()returns'pending'with apendingId, instantly, no blocking. - Any other, already-approved call in the same batch still runs for
real right then. The turn stops there:
stopReason: 'pending_approval'(or'pending_question'),newMessagesending at the dangling assistant tool_use message — the same crash-recovery shapesession-store.ts's ownhasUnresolvedToolCallalready detects, entered on purpose. - The adapter (
adapters/http.ts'screateCheckpointFromPending) turnsresult.pendinginto a durableTurnCheckpointviacheckpoints.create(...). The notifier has already fired its notification from inside step 1 — the process can exit, nothing is held open. - Minutes or days later:
POST /pending-approvals/:pendingId/resolve(or/pending-questions/:pendingId/answer) arrives, decoupled from any live connection or process.checkpoints.withCheckpoint(pendingId, fn)loads the checkpoint (or handsfnundefinedfor an unknown/ already-closedpendingId— reported as{alreadyResolved: true}, a graceful no-op). fnexecutes the tool for real (validatingeditedArgsfirst, if given) or synthesizes a denial/records an answer. A denial closes the whole checkpoint immediately, synthesizingskippedfor any other still-outstanding item in the same batch.- If
outstandingis still non-empty after this one resolution, the route responds{resolved: true, outstanding: N}and stops there. - Once
outstandingis empty,sessions.withSession(...)loads durable history and callsresumeAgent(config, modelCall, history, resultsSoFar, options)— pushes the now-completetool_resultmessage and picks the loop back up from "call the model again." The checkpoint itself is already gone by this point. - Two ways this can go: a genuine finish (the final answer comes back
directly in the HTTP response to whoever called resolve/answer —
AgentConfig.onRunFinishis what reaches anyone else), or another pending call (result.pendinggets turned into a freshTurnCheckpointthe same way step 3 did for the original one).
Open questions — not yet decided
- Magic-link/resolve-route security. loopengine's own resolve routes
still treat
pendingIditself as the only credential, under whateverLOOPENGINE_ADMIN_AUTHBasic Auth is configured — that part stays loopengine's own trust model regardless of notification channel.core/http-notify-triggers/email.ts'sEmailNotifieradds a signed, expiring token of its own on top, one layer in front of loopengine's routes, specifically because a clicked email link has no signature header the way a chat platform's own callback does and might sit in an inbox for days. Real revocation (what a stale emailed link should do if the decision was already made via Slack) still isn't built anywhere — the token's own expiry is a time-bound mitigation, not revocation. - Per-agent webhook discovery for a background dispatcher. For an
agent invoked both live and in the background, exactly how the
dispatcher-side code discovers which webhook destination to construct
options.approverwith, per agent. Treated as ordinary application wiring rather than a new protocol concept — no dispatcher has actually been built yet to test that assumption against. - A Slack-backed
DurableApproveras a realactauthpackage class (rather than loopengine's owncore/http-notify-triggers/slack.ts) — see Notification above for why that split isactauth's own call, not loopengine's. NeitherSlackNotifiernorLarkNotifierhas been exercised against a real Slack/Lark app end to end yet, only reasoned through against each platform's documented API shape.