---
title: "Run an agent"
description: "Route your model clients through the proxy as an agent, sync or async, with OpenAI, Anthropic or any httpx client, and handle what the proxy refuses."
source: "Guardian Agent SDK"
---
`guardian.agent()` names an enrolled agent and describes it ([Describe an agent](./describe-an-agent.mdx)). `bind()` then gives you a copy of the model client you already use, routed through the proxy and naming the agent on every request, so each one is signed as that agent. Your client is left as it was.

- **OpenAI's `OpenAI` or `AsyncOpenAI`:** `agent.bind(OpenAI())`.
- **Anthropic's `Anthropic` or `AsyncAnthropic`:** `agent.bind(Anthropic())`.
- **LangChain's `ChatOpenAI` or `ChatAnthropic`,** with the `langchain` extra: `agent.bind(ChatOpenAI(model="gpt-4.1"))`.
- **Anything that takes an `httpx` client:** `agent.httpx_client()` or `agent.httpx_async_client()`.

## Send what the definition says

The definition records the agent's instructions, tools and model settings, and the proxy signs every request as matching it. With a model client you call yourself, the SDK adds the agent's name and definition to each request but doesn't change what it sends: your requests must send the instructions and tools the definition records, to the model it names. The framework integrations build each request from what they describe, and check each call against the definition.

## OpenAI and Anthropic clients

`bind()` takes OpenAI's and Anthropic's clients, sync and async. The Anthropic example runs through a proxy aimed at Anthropic's endpoint on the Agent Gateway, with `VIPER_PROXY_URL` set to it, and needs the `anthropic` package and `ANTHROPIC_API_KEY` ([Before you start](./index.mdx#a-proxy-for-each-provider)):

{/* generated file sdks/python/snippets/run_async.py python */}

```python
import asyncio

from openai import AsyncOpenAI

import guardian_agent as ga

INSTRUCTIONS = "You research questions and cite your sources."


async def main() -> None:
    guardian = ga.Guardian()
    researcher = guardian.agent(
        "researcher",
        model=ga.Model("openai", "gpt-4.1"),
        instructions=INSTRUCTIONS,
    )
    await researcher.aregister()  # optional: fail at start-up, not on the first user

    # Close the client when you're done, as for any async client
    async with researcher.bind(AsyncOpenAI()) as client:
        reply = await client.chat.completions.create(
            model="gpt-4.1",
            messages=[
                {"role": "system", "content": INSTRUCTIONS},
                {"role": "user", "content": "Summarise the release notes."},
            ],
        )
        print(reply.choices[0].message.content)


asyncio.run(main())
```

{/* end generated */}

{/* generated file sdks/python/snippets/run_anthropic.py python */}

```python
from anthropic import Anthropic
from anthropic.types import TextBlock

import guardian_agent as ga

INSTRUCTIONS = "You research questions and cite your sources."

# VIPER_PROXY_URL names a proxy started with --gateway-endpoint ".../v1/anthropic_api"
guardian = ga.Guardian()
researcher = guardian.agent(
    "researcher",
    model=ga.Model("anthropic", "claude-sonnet-5", max_tokens=1024),
    instructions=INSTRUCTIONS,
)

client = researcher.bind(Anthropic())
reply = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    system=INSTRUCTIONS,
    messages=[{"role": "user", "content": "Summarise the release notes."}],
)
print("".join(block.text for block in reply.content if isinstance(block, TextBlock)))
```

{/* end generated */}

- **Where the client points.** A client still aimed at the provider's own API, as a client is by default, is pointed at the proxy. One aimed anywhere else is refused with `ga.ModelNotRoutedThroughProxy`, since its requests would never be signed. One already aimed at the proxy needs the right path: `/v1` for OpenAI's clients, none for Anthropic's, or `bind()` raises `ValueError`.
- **Your provider key** is untouched: Viper and the Agent Gateway pass it through to the provider.
- **The copy's HTTP client** keeps the original's timeout, redirect settings and event hooks, and the provider SDK's connection limits. Other HTTP customisation, such as a custom CA, doesn't carry over, and isn't needed to reach a proxy on your own machine.
- **OpenAI's Realtime (WebSocket) API** can't go through the proxy, so a bound client's Realtime connections fail rather than go unsigned: `bind()` aims them at an address that never resolves. That holds until you aim the copy elsewhere yourself, with `with_options(websocket_base_url=...)`.

## LangChain chat models

With the `langchain` extra and your provider's LangChain package (`langchain-openai`, `langchain-anthropic`), `bind()` takes LangChain's `ChatOpenAI` and `ChatAnthropic`:

{/* generated file sdks/python/snippets/langchain_bind.py python */}

```python
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI

import guardian_agent as ga

INSTRUCTIONS = "You research questions and cite your sources."

# The definition takes the chat model itself, so it records its settings exactly
llm = ChatOpenAI(model="gpt-4.1", temperature=0)

guardian = ga.Guardian()
researcher = guardian.agent("researcher", model=llm, instructions=INSTRUCTIONS)

routed = researcher.bind(llm)  # a copy, routed through the proxy; llm is unchanged
reply = routed.invoke(
    [SystemMessage(INSTRUCTIONS), HumanMessage("Summarise the release notes.")]
)
print(reply.content)
```

{/* end generated */}

The copy routes every call it makes, sync or async, streamed or not, and through `bind_tools()` and `with_structured_output()`: bind the model before those. A LangChain model has to be one the definition describes exactly, settings included, or `bind()` raises `ValueError`, since its requests would be signed under a definition that doesn't describe them. Giving the definition the chat model itself, as above, makes them match. It raises `TypeError` for a model it can't route: a model name such as `"openai:gpt-4.1"` (build it with `init_chat_model` and bind that), one already wrapped by `bind_tools()` or `with_structured_output()`, or another kind of chat model. [Plain LangChain](../frameworks/plain-langchain.mdx) covers what binding records and refuses in full, and [Frameworks](../frameworks/index.mdx) covers agents built with `create_agent`, Deep Agents and LangGraph.

## Any httpx client

For anything else that takes an `httpx` client, `agent.httpx_client()` and `agent.httpx_async_client()` give you one whose base URL is the proxy's. A request anywhere else is refused rather than sent:

{/* generated file sdks/python/snippets/httpx_client.py python */}

```python
import os

import guardian_agent as ga

INSTRUCTIONS = "You research questions and cite your sources."

guardian = ga.Guardian()
researcher = guardian.agent(
    "researcher",
    model=ga.Model("openai", "gpt-4.1"),
    instructions=INSTRUCTIONS,
)

# For a client that takes an httpx client: its base URL is the proxy's,
# and a request anywhere else is refused rather than sent
with researcher.httpx_client(timeout=30) as http:
    response = http.post(
        "/v1/chat/completions",
        headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
        json={
            "model": "gpt-4.1",
            "messages": [
                {"role": "system", "content": INSTRUCTIONS},
                {"role": "user", "content": "Summarise the release notes."},
            ],
        },
    )
    print(response.json()["choices"][0]["message"]["content"])
```

{/* end generated */}

They take `httpx.Client`'s options, such as `headers`, `timeout`, `auth`, `limits`, `verify` and `http2`, and the transport's `retries` and `local_address`. Options that could send requests around the agent (`transport`, `mounts`, `proxy`) raise `TypeError`, as do `trust_env`, which a client with its own transport ignores, and any option the SDK doesn't know. `base_url` defaults to the proxy's address. A request to any path at that address goes through. A request to anywhere else is never sent: the client gets a `421` response carrying the `model_not_routed_through_proxy` refusal, and `ga.proxy_error(response)` returns it as a `ga.ModelNotRoutedThroughProxy`.

## One proxy per provider

A proxy fronts one provider's gateway endpoint (`viper proxy --gateway-endpoint`): `/v1/openai_api` for OpenAI's clients, or `/v1/anthropic_api` for Anthropic's. An agent that uses both runs through two proxies ([Before you start](./index.mdx#a-proxy-for-each-provider) shows how to start them). Make the agent from a `Guardian` for each, with the same name and definition:

{/* generated file sdks/python/snippets/two_providers.py python */}

```python
import os

from anthropic import Anthropic
from anthropic.types import TextBlock
from openai import OpenAI

import guardian_agent as ga

INSTRUCTIONS = "You research questions and cite your sources."

# One proxy per gateway endpoint: --gateway-endpoint ".../v1/openai_api" for one,
# ".../v1/anthropic_api" for the other
openai_proxy = ga.Guardian(os.environ["OPENAI_PROXY_URL"])
anthropic_proxy = ga.Guardian(os.environ["ANTHROPIC_PROXY_URL"])

# Both models the agent calls, one through each proxy (a list is also fallback order)
definition = ga.Definition(
    model=[
        ga.Model("openai", "gpt-4.1"),
        ga.Model("anthropic", "claude-sonnet-5", max_tokens=1024),
    ],
    instructions=INSTRUCTIONS,
)

# The same agent, with the same name and definition, through each proxy
drafting = openai_proxy.agent("researcher", definition=definition).bind(OpenAI())
reviewing = anthropic_proxy.agent("researcher", definition=definition).bind(Anthropic())

draft = drafting.chat.completions.create(
    model="gpt-4.1",
    messages=[
        {"role": "system", "content": INSTRUCTIONS},
        {"role": "user", "content": "Draft the release summary."},
    ],
)
review = reviewing.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    system=INSTRUCTIONS,
    messages=[
        {
            "role": "user",
            "content": f"Check this draft:\n{draft.choices[0].message.content}",
        }
    ],
)
print("".join(block.text for block in review.content if isinstance(block, TextBlock)))
```

{/* end generated */}

Enroll the agent on the machine once: each proxy loads every agent enrolled on it when it starts, so both hold its key.

The framework integrations take one `guardian`, so they refuse an agent whose models come from both providers ([One provider per agent](../frameworks/index.mdx#one-provider-per-agent-for-now)).

## What happens when

- **`guardian.agent(...)`** checks the definition locally. Nothing is sent.
- **The first request** checks the proxy's capabilities (once per `Guardian`) and registers the definition. Requests made at the same moment share one registration, and if it fails, every request waiting on it gets that failure.
- **Every HTTP request** carries `X-Viper-Agent`, `X-Viper-Definition` and, for a sub-agent, `X-Viper-Subagent`. Nothing else is added.
- **After a proxy restart**, the proxy has forgotten the definition: the SDK registers it again and retries the request once.

To fail at start-up instead of on the first user's request, call `agent.register()`, or `await agent.aregister()`, as the async example above does. It returns the definition's content ID, also at `agent.definition_cid`. Once registered it doesn't contact the proxy again, so it isn't a health check: the next model request after a restart registers again.

## Sub-agents

A sub-agent signs as its parent. Route a client through one by its path, as in `writer.subagent("critic").bind(OpenAI())` or `writer.subagent("critic/fact-checker")` for a nested one ([Agents and identities](../concepts/agents-and-identities.mdx#sub-agents)). A name the definition doesn't declare raises `ga.SubagentUnknown` at `subagent()`, not on the first request.

## When the proxy refuses a request

A refusal reaches you through your model client, as its own error type, so its retries and error handling keep working. Turn it back into this SDK's error with `ga.proxy_error()`:

{/* generated file sdks/python/snippets/handle_refusals.py python */}

```python
import openai

import guardian_agent as ga

INSTRUCTIONS = "You research questions and cite your sources."

guardian = ga.Guardian()
researcher = guardian.agent(
    "researcher",
    model=ga.Model("openai", "gpt-4.1"),
    instructions=INSTRUCTIONS,
)
client = researcher.bind(openai.OpenAI())

try:
    reply = client.chat.completions.create(
        model="gpt-4.1",
        messages=[
            {"role": "system", "content": INSTRUCTIONS},
            {"role": "user", "content": "Summarise the release notes."},
        ],
    )
    print(reply.choices[0].message.content)
except openai.APIStatusError as error:
    # The proxy's refusal as this SDK's typed error, or None for the provider's own
    if refusal := ga.proxy_error(error):
        print(f"refused: {refusal.code}. {refusal.hint or ''}")
    raise
```

{/* end generated */}

- **`proxy_error()`** returns the SDK's typed error for a refusal by the proxy, such as `ga.UpstreamResponseLost` or `ga.SigningFailed`, or by the SDK on its behalf, such as a failed registration or a request aimed elsewhere (`ga.ModelNotRoutedThroughProxy`), with its `code`, `message`, `hint` and `status`. It takes the client's error, or a response.
- **It returns `None`** for errors that aren't the proxy's: the model provider's, and the Guardian Platform's own refusals, which the proxy passes on unchanged in the gateway's shape. A revoked agent, for example, arrives as a `403` with code `agent_revoked`, and a key the gateway doesn't know as a `401` with code `key_not_registered`, as a bad provider key would. The Guardian Platform can also refuse the key earlier, while the proxy sends the request's attestation, which arrives as the proxy's `signing_failed` ([Errors](../reference/errors.mdx#model-requests)).
- **Retries:** refusals that retrying can't fix, or could repeat (`upstream_response_lost`, since the model may have run), are marked so OpenAI's and Anthropic's clients don't retry them. If the proxy can't be reached at all, the client sees a connection error and retries as it would for the provider.

[Errors](../api/errors.mdx) lists every error and its code.

## Calls the SDK can't see

The SDK routes the clients you bind, and the framework integrations route the models they're given when an agent is made, and refuse a model they find but can't route, but nothing can see a model your code builds or looks up as it runs. Two things stop such a call going out unsigned, and they cover different calls:

- **A call aimed at the proxy** that names no agent is refused when you run the proxy with `--require-agent-header`.
- **A call aimed straight at a provider** (`api.openai.com`, say) never reaches the proxy, so the proxy can't refuse it. Only the network can: let the agent reach model providers only through the proxy, with a container's egress rules or a sandbox, such as [OpenShell](https://guardian.docs.eqtylab.io/user-guide/openshell-quickstart/).

Every model call is signed only where both hold.