CopilotKit

Agent Config

Forward typed configuration from your UI into the agent's reasoning loop.


using System.Diagnostics.CodeAnalysis;using System.Runtime.CompilerServices;using System.Text.Json;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using Microsoft.Extensions.Logging;using Microsoft.Extensions.Logging.Abstractions;// AgentConfigAgent — the /agent-config demo.//// Reads three forwarded properties — tone, expertise, responseLength — from// the AG-UI shared-state payload (attached as `ag_ui_state` on// ChatClientAgentRunOptions.AdditionalProperties, matching the convention// already used by SharedStateAgent) and builds a dynamic system prompt per// turn.//// The frontend <CopilotKitProvider agent="agent-config-demo" />'s// useAgent().setState(...) call pushes the typed config into shared state;// this agent reads it on every run and prepends a system message that adapts// the inner ChatClientAgent's behavior. Missing / unrecognized values fall// back to the documented defaults — the agent never throws on malformed// config, so a misbehaving frontend can't kill the demo.[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by SalesAgentFactory")]internal sealed class AgentConfigAgent : DelegatingAIAgent{    private static readonly HashSet<string> ValidTones = new(StringComparer.Ordinal)    {        "professional",        "casual",        "enthusiastic",    };    private static readonly HashSet<string> ValidExpertise = new(StringComparer.Ordinal)    {        "beginner",        "intermediate",        "expert",    };    private static readonly HashSet<string> ValidResponseLengths = new(StringComparer.Ordinal)    {        "concise",        "detailed",    };    private const string DefaultTone = "professional";    private const string DefaultExpertise = "intermediate";    private const string DefaultResponseLength = "concise";    private readonly ILogger<AgentConfigAgent> _logger;    public AgentConfigAgent(AIAgent innerAgent, ILogger<AgentConfigAgent>? logger = null)        : base(innerAgent)    {        ArgumentNullException.ThrowIfNull(innerAgent);        _logger = logger ?? NullLogger<AgentConfigAgent>.Instance;    }    public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)    {        return RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);    }    public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(        IEnumerable<ChatMessage> messages,        AgentThread? thread = null,        AgentRunOptions? options = null,        [EnumeratorCancellation] CancellationToken cancellationToken = default)    {        ArgumentNullException.ThrowIfNull(messages);        // Materialize up-front so we can both inspect it (to read the state)        // and forward it to the inner agent without re-enumerating a        // single-use iterator.        var messageList = messages as IReadOnlyList<ChatMessage> ?? messages.ToList();        var (tone, expertise, responseLength) = ReadConfig(options);        var systemPrompt = BuildSystemPrompt(tone, expertise, responseLength);        _logger.LogInformation(            "AgentConfigAgent: tone={Tone}, expertise={Expertise}, responseLength={ResponseLength}",            tone, expertise, responseLength);        var systemMessage = new ChatMessage(ChatRole.System, systemPrompt);        var augmentedMessages = new List<ChatMessage>(messageList.Count + 1) { systemMessage };        augmentedMessages.AddRange(messageList);        await foreach (var update in InnerAgent.RunStreamingAsync(augmentedMessages, thread, options, cancellationToken).ConfigureAwait(false))        {            yield return update;        }    }    /// <summary>    /// Reads the forwarded config triple from the AG-UI shared-state payload    /// attached to the run options. Any missing / unrecognized value falls    /// back to the corresponding default constant. Never throws.    /// </summary>    internal static (string Tone, string Expertise, string ResponseLength) ReadConfig(AgentRunOptions? options)    {        if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } ||            !properties.TryGetValue("ag_ui_state", out JsonElement state) ||            state.ValueKind != JsonValueKind.Object)        {            return (DefaultTone, DefaultExpertise, DefaultResponseLength);        }        var tone = ReadStringProperty(state, "tone", ValidTones, DefaultTone);        var expertise = ReadStringProperty(state, "expertise", ValidExpertise, DefaultExpertise);        var responseLength = ReadStringProperty(state, "responseLength", ValidResponseLengths, DefaultResponseLength);        return (tone, expertise, responseLength);    }    private static string ReadStringProperty(JsonElement state, string name, HashSet<string> valid, string defaultValue)    {        if (!state.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.String)        {            return defaultValue;        }        var value = element.GetString();        return value is not null && valid.Contains(value) ? value : defaultValue;    }    internal static string BuildSystemPrompt(string tone, string expertise, string responseLength)    {        var toneRule = tone switch        {            "casual" => "Use friendly, conversational language. Contractions OK. Light humor welcome.",            "enthusiastic" => "Use upbeat, energetic language. Exclamation points OK. Emoji OK.",            _ => "Use neutral, precise language. No emoji. Short sentences.",        };        var expertiseRule = expertise switch        {            "beginner" => "Assume no prior knowledge. Define jargon. Use analogies.",            "expert" => "Assume technical fluency. Use precise terminology. Skip basics.",            _ => "Assume common terms are understood; explain specialized terms.",        };        var lengthRule = responseLength switch        {            "detailed" => "Respond in multiple paragraphs with examples where relevant.",            _ => "Respond in 1-3 sentences.",        };        return "You are a helpful assistant.\n\n" +            $"Tone: {toneRule}\n" +            $"Expertise level: {expertiseRule}\n" +            $"Response length: {lengthRule}";    }}

You have a working agent and want the user to be able to tune how it behaves: tone, expertise level, response length, language, persona. By the end of this guide, your UI will own a typed config object that the agent reads on every run and rebuilds its system prompt from.

When to use this#

Reach for agent config whenever the agent's behaviour depends on user-controllable settings that don't fit naturally as chat input:

  • Tone, voice, persona: "playful", "formal", "casual"
  • Expertise level: "beginner", "intermediate", "expert"
  • Response shape: short / medium / long, structured / prose, language
  • Domain switches: which knowledge base to consult, which tool subset to enable

If the values are a channel the user occasionally tunes (a settings panel, a toolbar of selects), agent config is the right shape. If the values are content the agent should write back to (notes, a document, a plan), use Shared State instead.

How agent config flows from the UI into the agent's reasoning loop depends on your runtime architecture. Agents living behind a runtime read it from agent state on every run, while in-process agents receive the same object as forwarded properties on the provider — same UX, slightly different wiring on each side.

How it works#

Agent config is a typed object the frontend owns and keeps in sync with the agent. There are two pieces: the UI side, which owns the React state and pushes every change into agent state, and the backend node, which reads those fields out of state and turns them into a system prompt.

The UI side stays simple. Hold the typed config in React state, then mirror every change into the agent through agent.setState({...}):

frontend/src/app/page.tsx — UI owns the typed config
function ConfigStateSync({ config }: { config: AgentConfig }) {
  const { agent } = useAgent({ agentId: "agent-config" });
  useEffect(() => {
    agent.setState({ ...config });
  }, [agent, config]);
  return null;
}

The backend half is also a single node. Read the config out of state at the top of every run and use it to build the system prompt for that turn:

backend/agent.py — agent reads config and rebuilds the system prompt
async def my_agent_node(state: AgentState, config: RunnableConfig):
    cfg = state.get("config", {})
    tone = cfg.get("tone", "casual")
    expertise = cfg.get("expertise", "intermediate")
    response_length = cfg.get("response_length", "medium")
    system_prompt = build_system_prompt(tone, expertise, response_length)
    # ...

The agent reads the latest typed config at the start of every turn, rebuilds the system prompt, runs the turn. This is the same shape as the shared-state write-side pattern; agent config is just a specific use of that pattern with a UI-owned typed object on top.