---
title: "Multiple agents"
description: "Run several agents in one process, each signing as itself: when to make a separate agent rather than a sub-agent, agents that call each other, and testing them."
source: "Guardian Agent SDK"
---
Every agent you make from one `Guardian`, or from several, signs its requests as itself: its name is on each request, and the proxy signs each one with that agent's key ([One proxy, many agents](../concepts/agents-and-identities.mdx#one-proxy-many-agents)). So any number of agents can run in one process, through one proxy.

## An agent, or a sub-agent

- **A separate agent** is enrolled on its own (`viper agent add`), with its own key, agent ID and definition. Make one for work the Guardian Platform should see, govern and revoke on its own: a planner and a researcher with different tools and different owners, say.
- **A sub-agent** is declared in its parent's definition and signs as its parent ([Sub-agents](../concepts/agents-and-identities.mdx#sub-agents)). Make one for a part of an agent's own work, such as Deep Agents' sub-agents or a critic your agent hands drafts to. It isn't enrolled.

Running one agent in several processes or containers is neither: it's one agent with several keys ([Several instances of one agent](../concepts/agents-and-identities.mdx#several-instances-of-one-agent)).

## Agents that call each other

An agent's tool can call another agent made with `create_agent` or `create_deep_agent`. The other agent signs its own requests, under its own name and definition, so the SDK allows it where it refuses a tool that calls an unrouted model ([Tools that call a model](./langchain-agents.mdx#tools-that-call-a-model)). A model bound to another agent, `guardian.agent("summarizer", model=llm).bind(llm)`, is allowed for the same reason.

This test has a planner whose tool asks a researcher. The proxy's replies are scripted, so the planner calls its tool, the researcher answers, and the planner finishes. It's a test module, which starts its own `FakeProxy` with both agents enrolled ([Test your agents](../guides/testing.mdx)). Save it under a `test_` name and run it with `pytest`, or `uv run pytest`:

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

```python
import json
from collections.abc import Iterator
from typing import Any

import pytest
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI

from guardian_agent.langchain import create_agent
from guardian_agent.testing import FakeProxy, FakeRequest


@pytest.fixture
def proxy(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeProxy]:
    with FakeProxy(agents=["planner", "researcher"]) as proxy:
        monkeypatch.setenv("VIPER_PROXY_URL", proxy.url)
        monkeypatch.setenv("OPENAI_API_KEY", "test")
        yield proxy


def completion(content: str | None, calls: list[Any] | None = None) -> dict[str, Any]:
    """A Chat Completions response, carrying the message the model returns."""
    message: dict[str, Any] = {"role": "assistant", "content": content}
    if calls:
        message["tool_calls"] = calls
    return {
        "id": "chatcmpl-test",
        "object": "chat.completion",
        "created": 0,
        "model": "gpt-4.1",
        "choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
    }


def script(request: FakeRequest) -> dict[str, Any]:
    """The planner asks the researcher once, then answers."""
    if request.agent == "researcher":
        return completion("The release adds signed definitions.")
    if any(m["role"] == "tool" for m in request.json["messages"]):
        return completion("Done: the release adds signed definitions.")
    question = json.dumps({"question": "What does the release add?"})
    call = {
        "id": "call-1",
        "type": "function",
        "function": {"name": "ask_researcher", "arguments": question},
    }
    return completion(None, [call])


def test_each_agent_signs_its_own_requests(proxy: FakeProxy) -> None:
    researcher = create_agent(
        ChatOpenAI(model="gpt-4.1"),
        system_prompt="You research one question and cite your sources.",
        name="researcher",
    )

    # A tool can call another agent: it signs its own requests, as itself
    @tool
    def ask_researcher(question: str) -> str:
        """Ask the researcher a question."""
        answer = researcher.invoke(
            {"messages": [{"role": "user", "content": question}]}
        )
        return str(answer["messages"][-1].content)

    planner = create_agent(
        ChatOpenAI(model="gpt-4.1"),
        tools=[ask_researcher],
        system_prompt="You plan the work and ask the researcher what you need.",
        name="planner",
    )
    proxy.reply(script)

    result = planner.invoke(
        {"messages": [{"role": "user", "content": "Plan the notes."}]}
    )

    assert (
        result["messages"][-1].content == "Done: the release adds signed definitions."
    )
    assert [r.agent for r in proxy.requests] == ["planner", "researcher", "planner"]
    assert sorted(proxy.definitions) == ["planner", "researcher"]
```

{/* end generated */}

The requests go out as `planner`, then `researcher`, from inside the planner's tool call, then `planner` again, and each agent registers its own definition. The planner's definition records its `ask_researcher` tool, not the researcher's model or prompt: those are the researcher's own.

In a hand-built graph, such an agent can also be a node, or be called from one. The graph records it by name, and the agent still signs as itself ([Agents inside a graph](./langgraph-graphs.mdx#agents-inside-a-graph)).

## Raw clients for several agents

With model clients you call yourself, bind one client per agent. Each copy names its own agent, and each request sends its own agent's instructions:

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

```python
from openai import OpenAI

import guardian_agent as ga

PLANNING = "You plan the work and say which step needs research."
RESEARCH = "You research questions and cite your sources."

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

# One client per agent: each copy names its own agent on every request
planning = planner.bind(OpenAI())
researching = researcher.bind(OpenAI())

plan = planning.chat.completions.create(
    model="gpt-4.1",
    messages=[
        {"role": "system", "content": PLANNING},
        {"role": "user", "content": "Plan this week's release notes."},
    ],
)
notes = researching.chat.completions.create(
    model="gpt-4.1",
    messages=[
        {"role": "system", "content": RESEARCH},
        {"role": "user", "content": "What changed in this week's release?"},
    ],
)
print(plan.choices[0].message.content)
print(notes.choices[0].message.content)
```

{/* end generated */}

Each agent registers its definition on its first request, or when you call its `register()`.

## Enrolling them

Each separate agent is enrolled on the machine its proxy runs on, as the [Before you start](../guides/index.mdx#the-agents-they-name) page does for `planner` and `report-writer`. The proxy loads enrolled agents when it starts, so restart it after enrolling another. An agent the proxy doesn't hold can't register: `register()` raises `ga.AgentNotEnrolled`, whose hint names the `viper agent add` command to run, and a bound client's first request fails with the provider's error, from which `ga.proxy_error(error)` returns the `ga.AgentNotEnrolled`.