Stream and approve
In shortStream an agent run in Python with astream(), pause it for human approval with interrupt(), then resume the same thread with a decision.
- 6 min read
- 11 sections
- Updated
- v0.10.0
- Markdown
You pause an agent for human approval by calling interrupt() inside a tool, streaming the run with astream(), and resuming the same thread with {"resume": decision}. The graph saves its state at the pause, so you can wait as long as you need before answering.
What you build
You build a financial advisor agent with two tools: check_balance and transfer_funds. The transfer tool pauses the graph to ask for approval before it moves money. Your script streams the run, reads the approval request from the stream, then resumes the thread with a decision.
This pattern suits any action you cannot undo: payments, deletions, outbound emails. It is not needed for read-only tools, where a pause only adds latency.
Prerequisites
Install 10xGraph with Google GenAI support and set your key:
pip install "10xgraph[google-genai]"
export GOOGLE_API_KEY=your-api-keyYou also need the hand-built graph from Build a graph by hand and the checkpointer from Add memory with threads. This step combines both.
Build the agent with an approval tool
The graph is the same shape as before: an agent node, a tool node and a router. Two things change. transfer_funds calls interrupt(), and the graph compiles with a checkpointer, which a pause needs in order to save state.
import asyncio
from tenxgraph.core.graph import Agent, StateGraph, ToolNode
from tenxgraph.core.state import AgentState, Message, StreamEvent
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.utils import END, interrupt
def check_balance() -> str:
"""Check the account balance."""
return "Current balance: $50,000"
def transfer_funds(amount: float, recipient: str) -> str:
"""Transfer funds to a recipient. Asks a human to approve first."""
# First run: this call stops the graph.
# Resumed run: it returns the value passed as {"resume": ...}.
decision = interrupt(
{"amount": amount, "recipient": recipient},
message=f"Approve transfer of ${amount} to {recipient}?",
reason="transfer_approval",
)
if not decision or not decision.get("approved"):
return "Transfer cancelled by user"
return f"Transferred ${amount} to {recipient}"
tool_node = ToolNode([check_balance, transfer_funds])
agent = Agent(
model="gemini-2.5-flash",
system_prompt=[
{
"role": "system",
"content": (
"You are a helpful financial advisor. Help users check their balance "
"and transfer funds when requested."
),
}
],
tool_node=tool_node,
)
def should_use_tools(state: AgentState) -> str:
"""Route to TOOL when the assistant asked for tools, back to MAIN after a result."""
if not state.context:
return END
last_message = state.context[-1]
if last_message.role == "assistant" and last_message.tools_calls:
return "TOOL"
if last_message.role == "tool":
return "MAIN"
return END
graph = StateGraph(AgentState)
graph.add_node("MAIN", agent)
graph.add_node("TOOL", tool_node)
graph.add_conditional_edges("MAIN", should_use_tools, {"TOOL": "TOOL", END: END})
graph.add_edge("TOOL", "MAIN")
graph.set_entry_point("MAIN")
# A pause is saved in the checkpointer, so one is required to resume.
app = graph.compile(checkpointer=InMemoryCheckpointer())interrupt() takes a value (what the human needs to see), plus optional message, reason and response_schema keyword arguments. It only works while a node or tool is running; elsewhere it raises RuntimeError.
Stream the run until it pauses
astream() yields StreamChunk objects as the graph runs. When interrupt() fires, the stream emits one updates chunk with status set to "interrupted", carries the request in chunk.data["interrupt"], and then ends. Add this to approve_agent.py:
async def run_until_pause(thread_id: str) -> dict | None:
"""Stream the first run. Return the interrupt request, or None if none was raised."""
request = None
async for chunk in app.astream(
{"messages": [Message.text_message("Transfer $5,000 to Alice")]},
config={"thread_id": thread_id},
):
if chunk.event == StreamEvent.MESSAGE and chunk.message:
if chunk.message.role == "assistant" and chunk.message.text():
print(f"Assistant: {chunk.message.text()}")
elif chunk.event == StreamEvent.UPDATES and chunk.data.get("status") == "interrupted":
request = chunk.data["interrupt"]
return requestThe request is a dictionary with the fields of the Interrupt model: id, key, node, value, message, reason, response_schema and tool_call_id. Your UI reads message and value to show the human what is being approved.
Resume the thread with a decision
To resume, call astream() again with the same thread_id and an input of {"resume": decision}. No new messages are sent. The paused tool runs again from the start, and this time interrupt() returns your decision.
async def resume_with(thread_id: str, decision: dict) -> None:
"""Resume the paused thread and stream the rest of the run."""
async for chunk in app.astream(
{"resume": decision},
config={"thread_id": thread_id},
):
if chunk.event == StreamEvent.MESSAGE and chunk.message:
if chunk.message.role == "assistant" and chunk.message.text():
print(f"Assistant: {chunk.message.text()}")
async def main() -> None:
thread_id = "approval-demo-1"
print("=== Streaming until approval is needed ===")
request = await run_until_pause(thread_id)
if request is None:
print("The agent finished without asking for approval.")
return
print(f"\nPaused: {request['message']}")
print(f"Details: {request['value']}")
# In a real app a person answers here. This tutorial approves automatically.
print("\n=== Resuming with approval ===")
await resume_with(thread_id, {"approved": True})
if __name__ == "__main__":
asyncio.run(main())Run it
python approve_agent.pyThe model words its replies differently on each run, so your output will differ. It looks like this:
=== Streaming until approval is needed ===
Assistant: I'll set up that transfer for you.
Paused: Approve transfer of $5000.0 to Alice?
Details: {'amount': 5000.0, 'recipient': 'Alice'}
=== Resuming with approval ===
Assistant: The transfer of $5,000 to Alice is complete.To check the other branch, change the decision to {"approved": False}. The tool returns “Transfer cancelled by user” and the assistant tells you the transfer did not happen.
If the first run prints “finished without asking for approval”, the model answered without calling the tool. Rephrase the request to be explicit, for example “Use transfer_funds to send $5,000 to Alice”.
How it works
The flow is stream, pause, resume. The first paragraph below is the short version, and the numbered steps explain what the graph does at each point.
A pause is not an error. interrupt() stops the graph by raising GraphInterrupt, which derives from BaseException so your tools’ except Exception blocks do not swallow it. The graph catches it, saves the state and ends the stream.
- Run: the agent asks for
transfer_funds, and theToolNodestarts it. - Pause:
interrupt()raises, the graph stores the request in the thread’s checkpoint, andastream()emits theinterruptedupdate, then stops. - Resume: your
{"resume": decision}input is recorded as the answer. The tool runs again from the top, andinterrupt()returns the decision instead of raising.
Key API details
| Name | Import | What it does |
|---|---|---|
interrupt(value=None, *, message=None, reason="input_required", response_schema=None) |
tenxgraph.utils |
Pauses the graph and returns the resume value on the next run, or None if the client cancelled. |
pending_interrupt(state) |
tenxgraph.utils |
Returns the Interrupt a paused state waits on, or None. Use it on a state from invoke or the checkpointer. |
app.astream(input_data, config=None, response_granularity=ResponseGranularity.LOW) |
compiled graph | Async generator of StreamChunk. Chunks have event, message, state, data, thread_id, run_id. |
{"resume": value} |
input to astream, ainvoke, invoke |
Resumes a thread paused by interrupt(). The value goes back to the waiting call. |
StreamEvent |
tenxgraph.core.state |
Chunk types: state, message, error, updates. |
Resuming a thread that is not paused at an interrupt() raises a ValueError.
The same pause works without streaming. With ainvoke(..., response_granularity=ResponseGranularity.FULL) the result contains the state, and pending_interrupt(result["state"]) returns the request. The full model, including interrupt_before and interrupt_after, is in Interrupts, and a task-focused version is in Add human approval.
What you learned
astream()yields chunks as the graph runs, and aninterruptedupdate chunk marks a pause.interrupt()inside a tool pauses the run and saves state in the checkpointer.{"resume": decision}on the samethread_idcontinues the run, andinterrupt()returns the decision.- The paused tool re-runs from the start, so side effects belong after the call.
Next step
Learn how to unit test your agent in Test and evaluate. For streaming internals, see Streaming.
Frequently asked questions
- How do I pause a 10xGraph agent for human approval?
- Call interrupt() inside a tool or node. The graph saves its state and ends the run. Run the same thread again with {"resume": value} and interrupt() returns that value.
- Does the tool run again when I resume?
- Yes. The paused tool or node runs again from the start, and interrupt() returns the resume value instead of pausing. Keep side effects after the interrupt() call.
- Do I need a checkpointer to resume?
- Yes. The paused state is stored in the checkpointer, so resuming needs one and the same thread_id.