Skip to content
beam-agents
GitHub

Human in the loop

Approval channels, timeout routing with Deny/Drop/Escalate, and the two layers that make timeouts fail closed.

StableImplemented, specified, and covered by tests in the repository.

An agent asks a person by staging an approval intent and suspending. The interesting part is not the asking — it is what happens when nobody answers.

The approval round tripA sequence diagram with four participants across the top — RunAgent, keyed state, the effector, and a human approver — and time running downwards. An external event activates RunAgent, which calls ctx.request_approval to stage an intent and returns Suspend. The commit writes a continuation holding the activation seq and snapshot, records the pending intent id, and arms HITL_TIMER at the deadline; the activation then ends. The approval intent leaves on the intents output, through the outbox, to the effector, which publishes it to the approver without executing it. The diagram then forks into two outcomes. If the approval arrives before the deadline, it re-enters as an ordinary element on the same entity key, the continuation is read back, and the agent is invoked again with ctx.is_resume true, completing on the output stream. If nobody answers, HITL_TIMER fires instead, the pure on_timeout policy runs, and its route either denies with deterministic bytes on the output stream, drops with a hitl_timeout record on the errors stream, or escalates a fresh intent on another channel; Deny and Drop clear the continuation, ending the wait.TIMERunAgentONE KEYkeyed statePER ENTITY KEYeffectorOUTSIDE BEAMapproverA PERSONexternal event, keyedrequest_approvalSTAGES AN INTENTSuspend commitscontinuationSEQ · SNAPSHOTpending idsHITL_TIMER ARMEDToolIntent · APPROVALON .intentspublished, never runIF THE APPROVAL ARRIVES IN TIMEApproval, same keycontinuation readsame activationCTX.IS_RESUMEComplete on .outputIF NOBODY ANSWERS BY deadline_msHITL_TIMER fireson_timeoutPURE · RETRY-SAFEDeny: on .outputDrop: on .errorsEscalate: ask againcleared on Deny/Drop
Time runs down, participants run across. Nothing is blocked during the wait — the activation ends and a Continuation in keyed state is all that survives it. The lower band is the fail-closed outcome, not a variation on the upper one.

Nothing is blocked while the wait runs. The activation returns, the bundle commits, and the only thing that outlives it is a Continuation in keyed state holding the suspended seq, the snapshot the agent handed to Suspend, and the intent ids nothing has answered yet. The answer, whenever it comes, is an ordinary keyed element on the approvals topic — the same shape as any other input.

Requesting an approval

async def large_transfer(ctx: ActivationContext) -> Complete | Suspend:
    """Hold a large transfer until a human approves it."""
    if not ctx.is_resume:
        ctx.request_approval('{"amount": 250000}', ttl_ms=APPROVAL_TTL_MS)
        return Suspend(
            snapshot=b"awaiting-approval",
            adapter="example",
            timeout_ms=SUSPENSION_TIMEOUT_MS,
        )

    approval = ctx.resume_approval
    assert approval is not None
    return Complete(output=b"approved" if approval.approved else b"rejected")
website/examples/human_in_the_loop.py (region: agent) — executed by the repository’s offline test tier.

ctx.request_approval stages an intent on the policy's approval channel. The tool_name it carries is the channel the effector routes to, not a registered tool. Suspend(timeout_ms=...) sets the real-time deadline; omitting it falls back to HitlPolicy.timeout_ms, which defaults to 24 hours.

Deciding what a timeout means

HitlPolicy.on_timeout is a pure function from FallbackContext to a route.

def deny_on_timeout(fallback: FallbackContext) -> Route:
    """Route an unanswered approval to a deterministic denial.

    Pure and synchronous. It reads only the `FallbackContext` it is handed —
    which carries the suspended `seq`, the persisted snapshot, the elapsed
    deadline, the timer's fire time, and the intent ids nothing answered — so a
    retried timer bundle reaches the same decision.
    """
    return Deny(output=b"denied:no-approval:" + str(fallback.seq).encode())
website/examples/human_in_the_loop.py (region: policy) — executed by the repository’s offline test tier.
RouteEffect
Deny(output=...)Emit deterministic bytes on .output and end the suspension. The default emits b"__hitl_timeout__".
Drop(reason=...)Emit nothing; record the timeout on .errors as hitl_timeout.
Escalate(tool_name=..., timeout_ms=...)Stage a fresh approval intent on another channel and extend the deadline.

Escalate is bounded by HitlPolicy.max_escalations, which defaults to 0. An unbounded escalate loop would be a fail-open hole — the entire point of the timer is that the wait ends.

Why the policy must be pure

The policy runs inside a timer callback, and a timer callback re-executes when its bundle is retried. A policy that read a clock, called the model, or generated unseeded randomness would reach a different decision on the retry than it did the first time.

So every value the policy could need is carried on the FallbackContext: the suspended seq, the persisted snapshot, the deadline_ms that elapsed, the timer's fired_at_ms, and the pending_intent_ids nothing ever answered. It must also be picklable — a module-level function, never a lambda or a closure — because the DoFn holds the policy and serializes it for the runner.

Failing closed at both layers

Layer 1 is the timer above. Layer 2 is the effector refusing any intent past its expires_at_ms, so a late approval cannot cause an effect after the runtime has already given up. refuse_expired treats a non-positive expiry as expired, never as unbounded.

Failing closed at both layersTwo rows, one per layer, each a guard with two exits. Layer 1 runs in the pipeline: an answer arriving on the entity key meets an admission check that can fail four ways, and either resumes the activation with ctx.is_resume true or is recorded on the errors stream as an orphaned_result whose detail is one of no_continuation, unknown_intent, deadline_passed, or intent_expired. Layer 2 runs in the effector, outside the pipeline: an intent arriving from the outbox is checked against its expires_at_ms before the dedup store is touched, and either the tool runs exactly once and its result re-enters the pipeline, or an EXPIRED result is published and the tool never runs at all. A non-positive expires_at_ms reads as expired, never as unbounded. Because both guards refuse independently, a late approval can neither resume an activation the runtime has given up on nor cause an effect.LAYER 1 · IN THE PIPELINEanswer arrivesON THE SAME KEYadmission checkFOUR WAYS TO FAILresumeCTX.IS_RESUME TRUEorphaned_resultON .errorsno_continuation · unknown_intentdeadline_passed · intent_expiredLAYER 2 · IN THE EFFECTORintent arrivesFROM THE OUTBOXexpires_at_msZERO READS AS EXPIREDtool runs onceRESULT RE-ENTERSEXPIRED resultNOTHING RANA LATE APPROVAL CANNOT ACT
Two guards in two processes, not one mechanism described twice. Layer 1 decides whether a suspension may resume; layer 2 decides whether an effect may happen at all. A late answer has to get past both, and gets past neither.

The two guards run in different processes and read different fields, which is what makes them two layers rather than one rule applied twice. Layer 1 decides whether a suspension may resume; layer 2 decides whether an effect may happen at all, and it decides it before the dedup store is touched, so an outage there cannot turn a deadline into an unbounded wait.

A result or approval that arrives with no live continuation is dropped to .errors as orphaned_result, with a detail naming why: no_continuation, unknown_intent, deadline_passed, or intent_expired.

What backs this page

Symbol
beam_agents.HitlPolicy
Symbol
beam_agents.Deny
Symbol
beam_agents.Drop
Symbol
beam_agents.Escalate
Symbol
beam_agents.FallbackContext
Source
src/beam_agents/hitl.py
Source
docs/hitl.md
Specification
openspec/specs/wire-schemas/spec.md
Specification
openspec/specs/human-in-the-loop/spec.md
Test
tests/test_hitl.py
Test
tests/core/test_dofn_hitl_timer.py
Test
tests/semantics/test_hitl_fail_closed.py
Example
human_in_the_loop.py