Skip to main content
This example is a support agent built with Pydantic AI. It looks orders up with a tool, and its refund tool is declared with requires_approval=True: Pydantic AI defers that call, AgentOS asks a person, and the run continues with the approval, the edited arguments or the rejection.
Tested with pydantic-ai-slim 2.62.0 and Python 3.13, using the conformance kit: 22 checks passed, including an approved, an edited and a rejected refund.

The bundle

The SDK wheel goes in the bundle’s vendor/ folder; see SDKs. PYDANTIC_AI_NO_BANNER=1 keeps Pydantic AI’s start-up banner out of the container’s log.

How the model is wired

OpenAIProvider takes an OpenAI client, so the agent hands it one made from inv.llm.openai_kwargs(asynchronous=True): the gateway’s base URL, the run token in the x-agentos-run-token header, and an HTTP client that never sends the token anywhere else. The model name from config.models reaches the gateway exactly as written (anthropic/claude-sonnet-4-5); Pydantic AI shows it as openai:anthropic/claude-sonnet-4-5 in its own logs, but sends the name unchanged. The agent is built inside the handler, on every invocation, because the token belongs to one invocation. The handler is async, which the SDK supports, so it uses the async state and ask calls (aget, aput, aask). With max_retries=0 a guardrail block isn’t retried; agentos.guardrail_block(error) finds it inside Pydantic AI’s ModelRequestRejectedError, and the run fails with blocked_by_guardrail. See Guardrails.

How the approval works

1

Pydantic AI defers the call

The agent’s output_type includes DeferredToolRequests, so a call to issue_refund ends the run with those requests instead of running the tool. The handler keeps the conversation in the run’s state (ModelMessagesTypeAdapter, as JSON) and asks one AgentOS hold with an item per call: the item’s id is the tool call id and its data the call’s arguments.
2

A person decides

The run is held until someone decides in the Review queue or through the Holds API.
3

The run continues

The handler loads the conversation and runs the agent again with deferred_tool_results: True for an approval, ToolApproved(override_args=…) for an edit, ToolDenied(comment) for a rejection. Pydantic AI runs the approved calls and the model answers. If the model asks for another refund, the same steps repeat.

Run it

1

Zip and deploy

Put the SDK wheel in vendor/ (see SDKs), set config.models.main to a model from your Models page, then zip the folder’s contents and upload them:
2

Ask a question

3

Ask for a refund

The run is held: the refund waits in the Review queue. A reviewer approves it, edits the amount, or rejects it there, or through the Holds API. The item’s id is the model’s tool call id: read it from the hold (GET …/holds/h_12).

Test it before you deploy

The conformance kit builds the bundle and runs it against a mock platform whose model follows call:<tool> {arguments} in a message, so these cases drive the tools and every decision without a real model:
conformance.yaml

Things to know

  • The conversation travels in run state. A run’s state value can be at most 1 MiB, so a very long conversation needs trimming before it is kept.
  • Code before the ask runs once. A start is invoked once; the resume continues from the kept conversation, so the model isn’t asked the first question again.
  • An expired ask has no decided items, so the example keeps the waiting call ids in run state and denies every one of them; the model then explains that nothing was refunded. Change decisions() if expiry should mean something else for you.

Next steps

Human approvals

Everything an ask can carry: several items, details, files, an expiry.

More frameworks

The same agent in other frameworks and languages.