Building a DelegateAgent on FabrCore

Eric Brasher February 20, 2026 at 3:40 PM 10 min read

When you have multiple specialized agents — a research assistant, a code helper, a project planner — the user shouldn't have to know which one to talk to. A DelegateAgent is an intelligent router that sits between the user and a pool of managed agents, using an LLM to select the right specialist for each request, formulate an optimized message, and deliver a clean response.

In this post, we'll build a complete DelegateAgent from scratch using FabrCore — specifically FabrCore.Host and FabrCore.Sdk. The agent runs a four-step pipeline for every incoming message: select the best agent, formulate the delegation message, delegate with a timeout, and analyze the response. No client-side code, no UI — just the server-side agent logic you drop into your own project.

When to use a DelegateAgent

Use a DelegateAgent when you have a multi-agent system and want a single entry point for users. Instead of asking users to pick the right agent, the DelegateAgent routes transparently. It's a single-turn router — each request is handled independently, no persistent workflow state. Think "smart receptionist," not "project manager."

1. Data Contracts

We need two simple POCOs. The first describes an available agent in the pool. The second is the structured output the LLM returns when selecting an agent:

Data Contracts
public class AvailableAgentInfo
{
    public string AgentName { get; set; } = "";
    public string Handle { get; set; } = "";
    public string Description { get; set; } = "";
    public string AgentType { get; set; } = "";
}

public class AgentSelectionOutput
{
    public string SelectedAgentName { get; set; } = "";
    public string Reasoning { get; set; } = "";
}

AvailableAgentInfo gets populated during discovery — the DelegateAgent queries each managed agent's health and pulls its name, handle, type, and description. AgentSelectionOutput is what the routing LLM returns: which agent it picked, and why. The Reasoning field is purely for observability — log it, and you can debug routing decisions without reproducing the full conversation.

2. Agent Skeleton

The DelegateAgent extends FabrCoreAgentProxy and registers itself with the [AgentAlias("delegate")] attribute so FabrCore can resolve it by type name:

DelegateAgent.cs — Class Declaration
using System.Text.Json;
using System.Text.Json.Serialization;
using FabrCore.Core;
using FabrCore.Sdk;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

[AgentAlias("delegate")]
public class DelegateAgent : FabrCoreAgentProxy
{
    private IChatClient? _routingClient;
    private AIAgent? _agent;
    private AgentSession? _session;
    private string _handlePrefix = "";

    private List<AvailableAgentInfo> _availableAgents = [];

    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        PropertyNameCaseInsensitive = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Converters = { new JsonStringEnumConverter() }
    };

    public DelegateAgent(
        AgentConfiguration config,
        IServiceProvider serviceProvider,
        IFabrCoreAgentHost fabrcoreAgentHost)
        : base(config, serviceProvider, fabrcoreAgentHost)
    {
    }
}

Key fields: _routingClient is a stateless IChatClient used only for agent selection calls. _agent and _session form a stateful chat client for formulation and analysis steps — they maintain conversation history so the DelegateAgent can reference prior exchanges. _handlePrefix comes from configuration and determines how agent handles are constructed (e.g., "myapp-user:").

3. Initialization

The OnInitialize override sets up the dual-client architecture and runs agent discovery:

DelegateAgent.cs — OnInitialize
public override async Task OnInitialize()
{
    var modelConfigName = config.Args?
        .GetValueOrDefault("ModelConfig") ?? "default";

    // Stateless client for routing decisions
    _routingClient = await GetChatClient(modelConfigName);

    // Stateful agent for formulation and analysis
    var result = await CreateChatClientAgent(
        modelConfigName,
        threadId: config.Handle ?? fabrcoreAgentHost.GetHandle(),
        tools: []  // No tools — delegates, not executes
    );

    _agent = result.Agent;
    _session = result.Session;

    await DiscoverAvailableAgents();
}

Notice tools: [] — the DelegateAgent has no tools of its own. It doesn't execute anything directly; it delegates. Tools belong to the managed agents. This is a deliberate design choice: the router should be a pure routing layer.

Two Chat Clients

The DelegateAgent maintains two separate chat clients: a _routingClient for stateless selection calls (clean, single-turn requests with no history), and an _agent/_session pair for conversational formulation and analysis (which benefit from seeing prior exchanges). This separation keeps routing decisions unbiased by prior conversation context.

4. Agent Discovery

Before the DelegateAgent can route anything, it needs to know what agents are available. Discovery happens at initialization and again if the cached list is empty when a message arrives.

The agent names come from the ManagedAgents configuration argument — a comma-separated list. The handle prefix comes from AgentHandlePrefix, so you control how handles are constructed for your deployment:

DelegateAgent.cs — DiscoverAvailableAgents
private async Task DiscoverAvailableAgents()
{
    _availableAgents = [];

    var managedAgentsCsv = config.Args?
        .GetValueOrDefault("ManagedAgents") ?? "";
    var agentNames = managedAgentsCsv
        .Split(',',
            StringSplitOptions.RemoveEmptyEntries |
            StringSplitOptions.TrimEntries)
        .ToList();

    _handlePrefix = config.Args?
        .GetValueOrDefault("AgentHandlePrefix") ?? "myapp-user";

    if (agentNames.Count == 0)
    {
        logger.LogWarning(
            "DelegateAgent has no ManagedAgents configured");
        return;
    }

    foreach (var name in agentNames)
    {
        var handle = $"{_handlePrefix}:{name}";
        try
        {
            var health = await fabrcoreAgentHost
                .GetAgentHealth(handle, HealthDetailLevel.Detailed);

            if (health.State == HealthState.Healthy
                && health.IsConfigured)
            {
                var desc =
                    health.Configuration?.Description
                        is { Length: > 0 } d ? d
                    : health.Configuration?.SystemPrompt
                        is { Length: > 0 } sp
                        ? sp[..Math.Min(200, sp.Length)]
                    : $"Agent '{name}' (type: {health.AgentType})";

                _availableAgents.Add(new AvailableAgentInfo
                {
                    AgentName = name,
                    Handle = handle,
                    AgentType = health.AgentType ?? "unknown",
                    Description = desc
                });
            }
            else
            {
                logger.LogWarning(
                    "Agent '{Name}' not healthy/configured — skipping",
                    name);
            }
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex,
                "Failed to get health for '{Name}' — skipping",
                name);
        }
    }

    logger.LogInformation(
        "DelegateAgent discovered {Count} agents: {Agents}",
        _availableAgents.Count,
        string.Join(", ",
            _availableAgents.Select(a => a.AgentName)));
}

The health check is the key piece. The DelegateAgent doesn't blindly forward messages to agent names — it verifies each agent is Healthy and IsConfigured through FabrCore's health reporting. Unhealthy agents are silently skipped. You can take an agent offline for maintenance and the router adapts automatically — no restart, no config change.

The description is pulled from the agent's configuration and becomes part of the catalog the routing LLM uses. If the agent has a Description field, that's used. Otherwise it falls back to the first 200 characters of the system prompt, or a generic label. The quality of the description directly affects routing accuracy — a well-described agent gets selected more reliably.

5. Agent Selection

When a user message arrives, the DelegateAgent's first job is to pick which managed agent should handle it. The SelectAgent method builds a catalog of available agents and asks the routing LLM to choose one:

DelegateAgent.cs — SelectAgent
private async Task<AgentSelectionOutput> SelectAgent(
    string userMessage)
{
    var agentCatalog = BuildAgentCatalog();

    var systemPrompt = $"""
        You are an intelligent message router. Given a user's
        request and a catalog of available agents, select the
        single best agent to handle the request.

        ## Available Agents
        {agentCatalog}

        ## Rules
        - Select exactly ONE agent by name (the AgentName field).
        - Choose the agent whose capabilities best match the
          user's request.
        - Provide brief reasoning for your selection.
        - If no agent is a clear match, choose the most
          general-purpose agent.
        """;

    return await ExtractJsonAsync<AgentSelectionOutput>(
        systemPrompt, userMessage);
}

private string BuildAgentCatalog()
{
    if (_availableAgents.Count == 0)
        return "(No agents available)";

    return string.Join("\n",
        _availableAgents.Select(a =>
            $"- **{a.AgentName}** (type: {a.AgentType}): {a.Description}"));
}

At runtime, the catalog might look like:

Example Agent Catalog (Generated)
- **assistant** (type: assistant): General-purpose AI assistant for everyday tasks
- **research** (type: assistant): Research assistant with web search capabilities
- **code-helper** (type: assistant): Code analysis agent with file system tools

If the model returns an agent name that doesn't match any available agent (rare with JSON schema enforcement, but possible), the DelegateAgent falls back to the first available agent rather than failing.

6. Structured JSON Extraction

The ExtractJsonAsync<T> helper is reusable across any agent that needs structured LLM output. It combines AIJsonUtilities.CreateJsonSchema from Microsoft.Extensions.AI with ChatResponseFormat.ForJsonSchema to get deterministic structured output:

DelegateAgent.cs — ExtractJsonAsync
private async Task<T> ExtractJsonAsync<T>(
    string systemPrompt, string userPrompt)
    where T : class, new()
{
    var schema = AIJsonUtilities.CreateJsonSchema(
        typeof(T));

    var chatOptions = new ChatOptions
    {
        Instructions = systemPrompt,
        ResponseFormat = ChatResponseFormat.ForJsonSchema(
            schema: schema,
            schemaName: typeof(T).Name,
            schemaDescription:
                $"Structured {typeof(T).Name} response")
    };

    var response = await _routingClient!.GetResponseAsync(
        [new ChatMessage(ChatRole.User, userPrompt)],
        chatOptions);

    var text = response.Text?.Trim() ?? "{}";
    var json = TryExtractJsonObject(text) ?? "{}";

    try
    {
        return JsonSerializer.Deserialize<T>(json, JsonOpts)
            ?? new T();
    }
    catch (JsonException ex)
    {
        logger.LogWarning(ex,
            "Failed to deserialize {Type}: {Json}",
            typeof(T).Name, json);
        return new T();
    }
}

private static string? TryExtractJsonObject(string text)
{
    var start = text.IndexOf('{');
    var end = text.LastIndexOf('}');
    if (start >= 0 && end > start)
        return text[start..(end + 1)];
    return null;
}

The flow: generate a JSON schema from the C# type, tell the model to conform to that schema, then extract and deserialize. The TryExtractJsonObject safety net handles cases where the model wraps its JSON in markdown fences or preamble text — it finds the first { and last } and extracts everything between. If deserialization still fails, the fallback returns a default instance rather than throwing.

7. The Delegation Pipeline

The OnMessage override is the core of the DelegateAgent — a four-step pipeline that handles every incoming message:

Filter Select Agent Formulate Delegate Analyze & Respond
DelegateAgent.cs — OnMessage
public override async Task<AgentMessage> OnMessage(
    AgentMessage message)
{
    // ── Filter non-user messages ──
    if (!string.IsNullOrEmpty(message.Channel)
        || message.MessageType is "thinking" or "status"
        || message.Kind == MessageKind.OneWay)
    {
        return message.Response();
    }

    var myHandle = fabrcoreAgentHost.GetHandle();
    var response = message.Response();
    var userMessage = message.Message ?? string.Empty;

    // Run compaction if needed
    await TryCompactAsync();

    try
    {
        // Re-discover if the cache is empty
        if (_availableAgents.Count == 0)
            await DiscoverAvailableAgents();

        if (_availableAgents.Count == 0)
        {
            response.Message = "No managed agents are available. "
                + "Please check the agent configuration.";
            return response;
        }

        // Step 1: Select the best agent
        var selection = await SelectAgent(userMessage);

        var selectedAgent = _availableAgents.FirstOrDefault(a =>
            string.Equals(a.AgentName,
                selection.SelectedAgentName,
                StringComparison.OrdinalIgnoreCase));

        if (selectedAgent is null)
        {
            logger.LogWarning(
                "LLM selected unknown agent '{Agent}', "
                + "falling back to first available",
                selection.SelectedAgentName);
            selectedAgent = _availableAgents[0];
        }

        logger.LogInformation(
            "Selected '{Agent}' — Reasoning: {Why}",
            selectedAgent.AgentName, selection.Reasoning);

        // Step 2: Formulate the delegation message
        var formulationPrompt = $"""
            The user sent the following request:
            ---
            {userMessage}
            ---

            You are delegating this to the
            **{selectedAgent.AgentName}** agent
            ({selectedAgent.Description}).

            Formulate the best possible message to send to
            this agent so it can fulfill the user's request
            effectively. Be clear, specific, and include all
            relevant context.
            Output ONLY the message to send.
            """;

        var formulationResult = await _agent!
            .RunAsync(formulationPrompt, _session);
        var delegationMessage =
            formulationResult.Text ?? userMessage;

        // Step 3: Delegate with timeout
        var taskMessage = new AgentMessage
        {
            ToHandle = selectedAgent.Handle,
            FromHandle = myHandle,
            Channel = "agent",
            Kind = MessageKind.Request,
            MessageType = "task",
            Message = delegationMessage
        };

        var timeoutSeconds = int.TryParse(
            config.Args?.GetValueOrDefault(
                "DelegationTimeoutSeconds"),
            out var ts) ? ts : 180;

        var responseText = await
            DelegateWithTimeoutAsync(
                taskMessage,
                selectedAgent.AgentName,
                timeoutSeconds);

        // Step 4: Analyze and formulate final response
        var analysisPrompt = $"""
            The user's original request was:
            ---
            {userMessage}
            ---

            You delegated to the **{selectedAgent.AgentName}**
            agent and received this response:
            ---
            {responseText}
            ---

            Analyze the response and formulate a clear, helpful
            reply for the user.
            If the agent's response fully answers the request,
            present it cleanly.
            If it's partial or unclear, note what was accomplished
            and what may still be needed.
            Output ONLY the final response for the user.
            """;

        var analysisResult = await _agent!
            .RunAsync(analysisPrompt, _session);
        response.Message = analysisResult.Text ?? responseText;
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Error in DelegateAgent.OnMessage");
        response.Message = "An error occurred while processing "
            + $"your request: {ex.Message}";
    }

    return response;
}

Let's break down the critical pieces.

Message Filtering

The filter at the top of OnMessage prevents infinite loops. When the DelegateAgent delegates to a managed agent, that agent may send messages back — not just the final response (which comes through SendAndReceiveMessage), but also thinking notifications, status updates, and other fire-and-forget messages. Without filtering, the DelegateAgent would try to route those as new user requests:

FilterWhat It CatchesWhy
!IsNullOrEmpty(Channel) Messages on named channels like "agent" Delegation responses arrive on the "agent" channel but are already handled inline by SendAndReceiveMessage. Processing them again would double-handle.
MessageType is "thinking" or "status" Thinking/status notifications from child agents Notifications from delegated agents delivered through the message stream. Display-only, not actionable requests.
Kind == OneWay Any fire-and-forget message Catch-all for notifications that don't expect a reply. Prevents routing of system-level messages.

Formulation

The user's raw message might be casual or assume context the target agent doesn't have. The formulation step runs through the persistent chat session (_agent/_session), so it has access to conversation history. If the user says "do the same thing for the third one," formulation can expand that into a self-contained request because it sees the prior exchanges.

The Task Message

The delegation message uses Channel = "agent" and Kind = MessageKind.Request. The channel tag is what the filter checks — it's what prevents the response from being re-routed. The Request kind means FabrCore expects a reply, which SendAndReceiveMessage awaits.

Response Analysis

The analysis step handles three cases: a complete answer (clean it up and present it), a partial result (note what's missing), and a timeout or error (translate into a user-friendly message). Like formulation, analysis runs through the persistent session, so the DelegateAgent's conversational memory builds naturally across requests.

8. Delegation with Timeout

The DelegateWithTimeoutAsync method wraps the actual agent-to-agent call in a race between the delegation and a configurable delay:

DelegateAgent.cs — DelegateWithTimeoutAsync
private async Task<string> DelegateWithTimeoutAsync(
    AgentMessage taskMessage,
    string agentName,
    int timeoutSeconds)
{
    try
    {
        var delegationTask = fabrcoreAgentHost
            .SendAndReceiveMessage(taskMessage);
        var timeoutTask = Task.Delay(
            TimeSpan.FromSeconds(timeoutSeconds));

        var completed = await Task.WhenAny(
            delegationTask, timeoutTask);

        if (completed == delegationTask)
        {
            var agentResponse = await delegationTask;
            return agentResponse.Message ?? "";
        }

        // Timeout — observe the late result to prevent
        // unobserved task exceptions
        _ = delegationTask.ContinueWith(
            t => logger.LogWarning(t.Exception,
                "Late response from {Agent} faulted",
                agentName),
            TaskContinuationOptions.OnlyOnFaulted);

        return
            $"[Timeout] The {agentName} agent did not respond "
            + $"within {timeoutSeconds} seconds. The request "
            + "may still be processing.";
    }
    catch (Exception ex)
    {
        logger.LogError(ex,
            "Delegation to {Agent} failed", agentName);
        return $"[Error] The {agentName} agent encountered "
            + $"an error: {ex.Message}";
    }
}

The Task.WhenAny pattern races the delegation against a delay. When the timeout wins, the delegation task is still running — the target agent is still processing. The ContinueWith(OnlyOnFaulted) continuation observes the abandoned task so that if it later throws, the .NET runtime doesn't raise an UnobservedTaskException that could crash the process.

Always observe abandoned tasks

When you use Task.WhenAny and abandon the losing task, you must observe it. If the abandoned task faults and nobody awaits it, the TaskScheduler.UnobservedTaskException event fires. In some configurations this crashes the process. The ContinueWith(OnlyOnFaulted) pattern cleanly logs and swallows the exception.

9. Configuration

Here's a complete fabrcore.json example with a DelegateAgent managing three specialized agents:

fabrcore.json — Full Example
{
  "Agents": [
    {
      "Handle": "myapp-user:delegate",
      "AgentType": "delegate",
      "Description": "Intelligent router for all user requests",
      "Args": {
        "ModelConfig": "default",
        "ManagedAgents": "assistant, research, code-helper",
        "DelegationTimeoutSeconds": "180",
        "AgentHandlePrefix": "myapp-user"
      }
    },
    {
      "Handle": "myapp-user:assistant",
      "AgentType": "assistant",
      "Description": "General-purpose AI assistant for everyday tasks"
    },
    {
      "Handle": "myapp-user:research",
      "AgentType": "assistant",
      "Description": "Research assistant with web search capabilities",
      "Args": { "Plugins": "WebSearch" }
    },
    {
      "Handle": "myapp-user:code-helper",
      "AgentType": "assistant",
      "Description": "Code analysis with file system access",
      "Args": { "Plugins": "FileSystem" }
    }
  ]
}
ArgumentDefaultDescription
ManagedAgents (required) Comma-separated list of agent names to route to. Each name maps to a handle {AgentHandlePrefix}:{name}.
AgentHandlePrefix "myapp-user" Prefix used when constructing agent handles. Set this to match your deployment's handle scheme.
DelegationTimeoutSeconds 180 Maximum seconds to wait for a delegated agent to respond before returning a timeout message.
ModelConfig "default" Name of the model configuration to use for routing, formulation, and analysis.

Wrapping Up

The DelegateAgent is a straightforward pattern: discover available agents at startup, then for every user message, run a four-step pipeline — select the best agent via structured LLM output, formulate an optimized delegation message, delegate with a timeout, and analyze the response before returning it to the user.

The key design decisions are the dual-client architecture (stateless routing vs. stateful formulation/analysis), the message filtering that prevents infinite loops in agent-to-agent communication, the health-check-based discovery that adapts to agents going offline, and the Task.WhenAny timeout pattern that gracefully handles slow agents.

Everything runs on FabrCore.Host and FabrCore.Sdk — drop the agent class into your project, add the configuration to fabrcore.json, and you have an intelligent message router.


Eric Brasher

Builder of FabrCore and OpenCaddis.