Skip to content

Multiple agents ​

An agent can work with others in two ways. It calls one as a sub-agent, a tool that answers with text, or it hands the conversation over to one.

Sub-agent or handoff ​

Sub-agentHandoff
Declared withUseAgent<T>()UseHandoff<T>()
The model callsA tool named like the agent, with one inputhandoff_to_{name}
Works withA fresh context and no conversation of its ownThe whole conversation
AnswersThe calling agent, which gets the text and goes on workingThe run, in place of the calling agent
Needs a conversationNoYes

Sub-agents ​

UseAgent<T>() offers another agent as a tool. The tool is named like the agent, described by its WithDescription, and takes one input parameter:

cs
internal partial class ReportAgent : Agent
{
    public ReportAgent()
    {
        WithChatOptions()
            .WithModel("anthropic", "claude-sonnet-5-5");

        UseAgent<ResearchAgent>();
    }

    protected override string GetInstructions() =>
        "Write a market report. Ask research_agent for the companies of each city you cover.";
}

snippet source | anchor

A call runs the sub-agent in a fresh context, with no conversation of its own, and returns its answer's text. It runs with the caller's settings id, so the settings stored for the sub-agent under that id apply. It also runs in the caller's mode and shares the caller's standing approvals and approval rules. In plan mode it researches with its read-only tools and answers the caller, which presents the plan. A sub-agent that fails, for example because no client serves its model, answers its caller with the exception's message, and the calling run goes on.

Its tool calls and tokens stay out of the caller's history, which keeps a long run's context small. A sub-agent can read twenty files and answer in one paragraph, and only the paragraph reaches the caller. When the calling agent's run options set WithConcurrentToolCalls(true), the sub-agents the model calls in one response work at the same time.

Run the calling agent like any other:

cs
var reports = scope.ServiceProvider.GetRequiredService<AgentHost<ReportAgent>>();

var report = await reports.RunAsync("Compare the robotics scenes of Vienna and Graz.", settingsId);

snippet source | anchor

A sub-agent needs a description and a lower_snake_case name (TSH023), because its name becomes the tool's and the model reads its description to decide when to call it. The analyzer rejects agents that call each other in a circle through UseAgent (TSH026), but handoffs may form circles.

A sub-agent cannot ask for approval

A sub-agent cannot stop to ask your app for approval. In the default mode, each of its calls that needs approval is rejected unless a standing approval or a rule covers it. The analyzer warns about such tools (TSH024), so add a rule, or hand off to the agent instead.

In the background ​

InBackground() starts the sub-agent and returns at once, so the caller goes on working:

cs
UseAgent<ExploreAgent>()
    .InBackground();

snippet source | anchor

When the sub-agent finishes, its answer reaches the calling run as a message before the run's next model call.

  • A run does not end while its sub-agents work. Once the model stops calling tools, the run waits for the next answer and continues with it.
  • A run that ends some other way, for example with an error, stops the sub-agents still working.
  • When a background sub-agent never answered, because the calling run ended first or the process crashed, the conversation's next run tells the model it was lost, so the model can call it again.

The sub-agent itself is an agent like any other. This one explores the workspace with a fast model and offers only the reading tools of the app's WorkspaceToolset, so none of its calls needs approval:

cs
internal partial class ExploreAgent : Agent
{
    public ExploreAgent()
    {
        WithDescription("Explores the workspace and answers one question about it, citing files and lines.");

        WithChatOptions()
            .WithModel("openai", "gpt-5-mini");

        UseToolset<WorkspaceToolset>()
            .Include(t => t.ReadFile)
            .Include(t => t.ListFiles)
            .Include(t => t.SearchFiles);
    }

    protected override string GetInstructions() =>
        "Answer the question from the files. Read only what you need; quote the lines you rely on.";
}

snippet source | anchor

Handoff ​

UseHandoff<T>() gives the model a tool, handoff_to_{name}, that carries the other agent's description. For BillingAgent it is handoff_to_billing_agent. When the model calls it, the other agent takes over and answers in the same run, with the whole conversation and its own instructions, tools and stored settings. Like a sub-agent, it needs a description and a lower_snake_case name (TSH023):

cs
internal partial class TriageAgent : Agent
{
    public TriageAgent()
    {
        WithChatOptions()
            .WithModel("openai", "gpt-5-mini");

        UseToolset<TicketToolset>()
            .Exclude(t => t.CloseTicket);

        WithRunOptions()
            .WithMaxToolIterations(10)
            .WithConcurrentToolCalls(true)
            .Configurable(r => r.Mode)
            .Configurable(r => r.MaxToolIterations);

        UseHandoff<SupportAgent>();
        UseHandoff<BillingAgent>();
    }

    protected override string GetInstructions() =>
        "Find out what the customer needs, then hand off to support or billing.";
}

snippet source | anchor

The conversation records which agent answered last, and its later runs go to that agent. That holds whichever host you start them on, as long as that host's agent can reach the last agent through its handoffs, directly or through other agents' handoffs. Each message carries its agent's name as AuthorName:

cs
var triage = scope.ServiceProvider.GetRequiredService<AgentHost<TriageAgent>>();
var conversationId = 21;

var first = await triage.RunAsync("I was charged twice.", settingsId, conversationId);
var second = await triage.RunAsync("It was invoice 2024-17.", settingsId, conversationId);

Console.WriteLine($"{second.Messages[^1].AuthorName}: {second.Text}");

snippet source | anchor

The Settings you pass to one run apply only to the agent you started it on. An agent it hands off to runs with its own settings stored under the same settings id.

Handoffs need a conversation

A run without a conversation id does not offer the handoff tools, so its agent cannot hand off.

Released under the MIT License.