Human approval over the API

In shortPause a refund agent with interrupt() and a response_schema, read the open request from the API, resume with resume and interrupt_id, and handle 409 and 422.

  • 4 min read
  • 6 sections
  • Updated
  • v1.0.0rc4
  • Markdown

An agent that can move money should ask first. This example puts interrupt() inside a refund tool, serves the graph with 10xgraph-api, and walks through the HTTP calls a frontend makes: start a run, read the open request, answer it, and handle the errors the server returns. Every response shown was captured from a running server.

Needs: pip install "10xgraph[openai]" "10xgraph-api[jwt]" and OPENAI_API_KEY for the model (or the Google or Anthropic equivalents). JWT_SECRET_KEY for auth. The issue_refund body is a placeholder for your payment code.

The graph

graph/agent.py
from tenxgraph.prebuilt.agent import ReactAgent
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.utils import interrupt, tool


@tool
def issue_refund(order_id: str, amount_cents: int) -> str:
    """Refund part or all of an order. A person must approve every refund."""
    decision = interrupt(
        {"order_id": order_id, "amount_cents": amount_cents},
        message=f"Approve a refund of {amount_cents / 100:.2f} for order {order_id}?",
        reason="tool_approval",
        response_schema={
            "type": "object",
            "properties": {"approved": {"type": "boolean"}},
            "required": ["approved"],
        },
    )
    if not decision or not decision.get("approved"):
        return "Refund declined."
    return f"Refunded {amount_cents} cents for order {order_id}"  # call your payment API here


app = ReactAgent(
    model="gpt-4o-mini",
    provider="openai",
    system_prompt=[{"role": "system", "content": "You are a support agent. Refund orders when asked."}],
    tools=[issue_refund],
).compile(checkpointer=InMemoryCheckpointer())  # use PgCheckpointer in production
10xgraph.json
{ "agent": "graph.agent:app", "auth": "jwt", "authorization": "ownership" }
Terminal
10xgraph dev --no-open

1. Start a run and read the open request

Ask for "response_granularity": "full"; the default does not include the state that holds the request.

Terminal
curl -s http://127.0.0.1:8000/v1/graph/invoke \
  -H "Authorization: Bearer $ALICE" -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":[{"type":"text","text":"Refund order 1042, 59.00"}]}],
       "response_granularity":"full"}'

The run ends without a final answer. In the response, data.meta.thread_id names the thread and data.state.execution_meta holds the pause:

JSON
{
  "current_node": "TOOL",
  "status": "interrupted_before",
  "interrupted_node": "TOOL",
  "interrupt_data": {
    "interrupt": {
      "id": "int_710c73f7a4a1479881718d9ad18a3c55",
      "key": "tool:c1:0",
      "node": "TOOL",
      "value": { "order_id": "1042", "amount_cents": 5900 },
      "message": "Approve charging 59.00 for order 1042?",
      "reason": "tool_approval",
      "response_schema": { "type": "object", "properties": { "approved": { "type": "boolean" } }, "required": ["approved"] },
      "tool_call_id": "c1"
    }
  }
}

Your UI renders message and value, and can build its form from response_schema. The id is what you send back. (This block was captured from a test graph with the same tool shape, so its message and tool_call_id differ from what your model-driven run produces.) When you stream (/v1/graph/stream), the pause arrives as an updates event with status: "interrupted".

2. Resume

Send resume and interrupt_id with the thread id. messages is not needed.

Terminal
curl -s http://127.0.0.1:8000/v1/graph/invoke \
  -H "Authorization: Bearer $ALICE" -H "Content-Type: application/json" \
  -d '{"resume":{"approved":true},
       "interrupt_id":"int_710c73f7a4a1479881718d9ad18a3c55",
       "config":{"thread_id":"<thread_id from step 1>"}}'

The paused tool runs again from its first line, interrupt() returns {"approved": true}, the refund runs once, and the model writes its answer. To refuse, send {"approved": false}. To cancel, send "resume": null; interrupt() returns None and a cancel is never checked against the schema.

The same call with the TypeScript client:

TypeScript
import { TenxGraphClient, Message, ConflictError, ValidationError } from '10xgraph-client';

const client = new TenxGraphClient({ baseUrl: 'http://127.0.0.1:8000', authToken: token });

const first = await client.invoke([Message.text_message('Refund order 1042, 59.00')], {
  response_granularity: 'full',
});
// The client's AgentState type does not declare interrupt_data yet, so read it loosely.
const meta = (first.state as any)?.execution_meta;
const pending = meta?.interrupt_data?.interrupt;
const threadId = first.meta.thread_id;

if (pending) {
  try {
    const done = await client.invoke([], {
      resume: { approved: true },
      interrupt_id: pending.id,
      config: { thread_id: threadId },
    });
    console.log(done.messages.at(-1));
  } catch (err) {
    if (err instanceof ConflictError) {
      // 409: the thread moved on, or another tab already answered.
    } else if (err instanceof ValidationError) {
      // 422: the answer does not match response_schema.
    } else {
      throw err;
    }
  }
}

3. The errors, as the server returns them

Situation Status Message
interrupt_id is not the open interrupt 409 interrupt_id 'int_nope' does not match the open interrupt 'int_710c...'
Resume sent to a thread that is not paused (also a repeated resume) 409 Nothing to resume: this thread is not paused at an interrupt
The answer breaks the schema ("approved": "yes") 422 Resume value does not match the response schema of interrupt '...': $.approved: expected boolean, got str
A new message sent to the paused thread 422 Thread is paused at interrupt '...' in node 'TOOL'. Resume it with {'resume': value}.
Another user’s token on this thread 403 Not authorized to invoke graph
Another run is active on the thread for more than 30 s 409 thread is busy

A rejected resume records nothing, so the thread stays paused and you can send a corrected answer.

When the thread is busy

One run at a time executes per thread. A second request on the same thread waits up to 30 seconds for the first, then gets 409 thread is busy in the standard error envelope. Send "on_busy": "cancel" to ask the active run to stop first, then run this request; the default is "wait". With the TypeScript client this is ConflictError, and the option is on_busy: 'cancel' on invoke.

What this shows

  • The pause is part of the checkpoint, so the person can answer later and a server restart in between loses nothing (with a durable checkpointer). See Interrupts.
  • The answer is bound to the question: interrupt_id stops a stale or repeated request from answering the next prompt, and response_schema rejects malformed answers before the tool runs. See Security model.
  • Only the thread’s owner can resume it; the check happens before the graph starts. See Auth.
  • One run per thread, with a bounded wait, keeps concurrent tabs from corrupting a thread. See Scaling and reliability.
  • Combine this with the payment example when the approved action moves money.

Frequently asked questions

What does interrupt_id protect against?
A retried or stale request cannot answer a different prompt. If the thread is not paused at that interrupt, the server answers 409 before anything runs.
Can I send a new message to a paused thread?
No. The server answers 422 and tells you to resume. A paused thread waits for an answer to one specific question.
Last updated for v1.0.0rc4Edit this page on GitHubReport an issue