Page actions

Microsoft Agent Framework Overview

Microsoft Agent Framework is Microsoft's Python and .NET agent SDK. Its AG-UI bridge turns an Agent into an AG-UI event stream, which is all @threadplane/ag-ui needs in order to bind it to <chat>. The running example is an expense assistant, and this page walks the four files that make it work.

What the demo does

The Run tab shows the prebuilt <chat> composition in front of an expense approval copilot served over AG-UI, with a shared-state panel beside it. Two starter suggestions are offered: "File a team dinner expense" and "File a monitor purchase". Pick either one and the agent researches the reimbursement policy, delegating part of that work to a specialist agent and calling a server-side policy tool, then drafts the expense: vendor, category, amount, and memo appear in the side panel while the model is still writing the tool call, before the tool has been invoked.

The draft never submits itself. submit_expense requires human approval, so the run pauses and a modal approval card shows the amount, vendor, category, and memo with Cancel and Approve buttons. Approve it and the agent confirms the submission in one sentence; cancel it and the agent acknowledges that nothing was filed.

How it is built

Four files carry the example: a Python module holding the agent and its tools, a FastAPI server that mounts it, an application config that registers the agent, and a component that renders the chat, the state panel, and the approval card. A fifth file next to the agent, src/subagent_emitter.py, holds the delegation events; it is discussed below but is not one of the four.

The policy tool

Tools are plain functions decorated with @tool. This one is the ordinary case: it executes server-side, returns a string, and never pauses the run.

agent.py — the policy tool
@tool(
    name="lookup_expense_policy",
    description="Look up the reimbursement policy for an expense category.",
)
def lookup_expense_policy(category: str) -> str:
    """Return the reimbursement policy for a category.
 
    Args:
        category: Expense category, e.g. 'meals' or 'travel'.
 
    Returns:
        A short policy summary string.
    """
    policy = _POLICIES.get(category.strip().lower())
    if policy is None:
        return f"No specific policy for '{category}'; the general limit is $200 with receipts required."
    return (
        f"Policy for {category}: limit ${policy['limit_usd']} per expense, "
        f"receipts required over ${policy['receipt_required_over_usd']}. {policy['notes']}"
    )

The decorator's name and description are the model-facing contract, which is why the categories live in the module-level policy table and the Expense.category field description rather than only in the system prompt.

The tool that requires approval

The second tool takes a single pydantic argument, an Expense with vendor, category, amount_usd, and memo fields, and it carries one extra decorator argument: approval_mode="always_require". That argument is the entire interrupt configuration. The framework stops before the body runs and asks the caller for a decision, and the bridge reports that pause to the client.

agent.py — the approval tool
@tool(
    name="submit_expense",
    description="Submit an expense report entry for reimbursement. Requires human approval.",
    approval_mode="always_require",
)
def submit_expense(expense: Expense) -> str:
    """Submit the expense for reimbursement once a human approves it.
 
    Args:
        expense: The complete expense entry (vendor, category, amount_usd, memo).
 
    Returns:
        A confirmation string with the recorded amount.
    """
    # On the approval-resume path the framework replays the stored tool-call
    # arguments as plain dicts rather than re-validating through pydantic —
    # normalize before reading attributes.
    entry = expense if isinstance(expense, Expense) else Expense.model_validate(expense)
    return (
        f"Expense recorded: ${entry.amount_usd:.2f} to {entry.vendor} "
        f"({entry.category}) — queued for reimbursement."
    )
Warning: Resumed arguments are not re-validated

On the approval-resume path the framework replays the stored tool-call arguments as plain dictionaries rather than re-validating them through pydantic. The body therefore normalizes with Expense.model_validate before reading attributes; a body that assumes a model instance raises on the resume turn only.

Delegating to a specialist

The delegation tool is research_policy. policy_researcher is a second Agent with its own instructions and no tools of its own, and research_policy streams it from inside a tool body. The bridge forwards nothing from an agent running inside a tool body on its own: only the tool's return string reaches the wire, as a tool result.

Note: How delegation reaches the wire

The delegation_* helpers close that gap. They live in src/subagent_emitter.py, they build typed ag_ui.core events, and they enqueue them onto a queue that a run wrapper merges into the bridge's own event stream, so SUBAGENT_STARTED, the specialist's TEXT_MESSAGE_* deltas stamped with a subagentRunId, and SUBAGENT_FINISHED all reach the client while the bridge generator is still suspended inside the tool. Outside that wrapped run the helpers are no-ops, which keeps direct agent runs free of side effects.

agent.py — the delegation tool
policy_researcher = Agent(
    name="policy_researcher",
    instructions=(
        "You are an expense-policy researcher. Given an expense category and "
        "amount, summarize the applicable policy rules in 3 short bullets."
    ),
    client=build_chat_client(),
)
 
 
@tool(
    name="research_policy",
    description="Delegate policy research for this expense to a specialist.",
)
async def research_policy(category: str, amount: float) -> str:
    """Delegate policy research for this expense to a specialist.
 
    Streams the ``policy_researcher`` specialist and mirrors each text delta
    onto the AG-UI wire as attributed SUBAGENT_* / TEXT_MESSAGE_* events via
    src/subagent_emitter.py (no-ops outside the wrapped run).
 
    Args:
        category: Expense category, e.g. 'meals' or 'travel'.
        amount: Expense amount in USD.
 
    Returns:
        The specialist's complete policy summary.
    """
    # Deterministically recorded by the run wrapper's pump before this body
    # runs (the bridge streams TOOL_CALL_START/ARGS/END first); None when
    # invoked outside a wrapped run.
    tid = subagent_emitter.current_tool_call_id("research_policy")
    subagent_emitter.delegation_started(tid, policy_researcher.name)
    parts: list[str] = []
    try:
        prompt = (
            f"Expense category: {category}. Amount: ${amount:.2f}. "
            "Summarize the applicable policy rules."
        )
        async for update in policy_researcher.run(prompt, stream=True):
            text = update.text
            if text:
                parts.append(text)
                subagent_emitter.delegation_delta(tid, text)
    except Exception as exc:
        subagent_emitter.delegation_error(tid, str(exc))
        raise
    subagent_emitter.delegation_finished(tid)
    return "".join(parts)

The wire capture behind the design is committed next to the module as python/docs/wire-capture-subagents.md.

Choosing the model client

Azure OpenAI is the default path, and the choice is made by one environment variable.

agent.py — the model client
def build_chat_client() -> OpenAIChatCompletionClient:
    """Azure OpenAI by default; plain OpenAI when Azure env is absent.
 
    Passing `azure_endpoint` explicitly is the constructor's strongest Azure
    signal — it wins even when OPENAI_API_KEY is also set, which makes Azure
    the default whenever it is configured. Key, deployment (model), and API
    version resolve from AZURE_OPENAI_* env vars.
    """
    azure_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
    if azure_endpoint:
        return OpenAIChatCompletionClient(
            model=os.environ.get("AZURE_OPENAI_MODEL"),
            azure_endpoint=azure_endpoint,
        )
    # Passing api_key explicitly forces OpenAI routing even when no
    # OPENAI_API_KEY env var is set; without it the constructor falls back to
    # Azure env resolution and raises at import time. A placeholder key fails
    # properly at request time (401) instead of at module import.
    return OpenAIChatCompletionClient(
        model=os.environ.get("OPENAI_CHAT_MODEL", "gpt-4o-mini"),
        api_key=os.environ.get("OPENAI_API_KEY", "unset-openai-api-key"),
    )

Passing azure_endpoint explicitly is the constructor's strongest Azure signal, so Azure wins whenever AZURE_OPENAI_ENDPOINT is set, even when OPENAI_API_KEY is also present. Without it the plain OpenAI client is constructed with an explicit key, because the constructor otherwise falls back to Azure environment resolution and raises at import time.

Exposing the agent over AG-UI

AgentFrameworkAgent wraps the framework agent and is the AG-UI surface. Two of its arguments carry the shared-state behavior: state_schema declares the shared-state keys the agent exposes; the bridge opens the run with them in the first STATE_SNAPSHOT. predict_state_config maps a tool argument onto one of those keys.

agent.py — the AG-UI agent
agent = AgentFrameworkAgent(
    agent=Agent(
        name="expense_approval_copilot",
        instructions=_INSTRUCTIONS,
        client=build_chat_client(),
        tools=[lookup_expense_policy, research_policy, submit_expense],
    ),
    name="ExpenseApprovalCopilot",
    description="Files expense reports with policy lookup, shared state, and human approval.",
    state_schema={
        "expense": {"type": "object", "description": "The expense entry being drafted."},
    },
    predict_state_config={
        "expense": {"tool": "submit_expense", "tool_argument": "expense"},
    },
    require_confirmation=False,
)

With that mapping in place the bridge streams the expense argument of submit_expense into frontend state as the model generates it, over STATE_SNAPSHOT and STATE_DELTA, rather than waiting for the tool call to complete.

Serving the agent

The backend is a FastAPI application, and add_agent_framework_fastapi_endpoint mounts the agent at a path that speaks the AG-UI event stream.

server.py
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
 
from .agent import agent
from .subagent_emitter import wrap_agent_run
 
app = FastAPI(title="cockpit-runtimes-microsoft-agent-framework")
# The wrapper is the SUBAGENT_* injection seam: the endpoint consumes
# protocol_runner.run, and wrap_agent_run merges the delegation tool's
# enqueued child events into that stream (src/subagent_emitter.py).
wrapped_agent = wrap_agent_run(agent)
add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/agent")
 
 
@app.get("/ok")
def ok() -> dict:
    return {"ok": True}

The agent handed to the endpoint is the wrapped one, because the wrapper is the seam where the delegation events are merged into the stream the endpoint consumes.

The agent provider

provideAgent() registers the agent once for the whole application, and it is the only provider the <chat> composition requires. Nothing about it is specific to this runtime. The example passes a factory because it resolves its endpoint at runtime from the host that serves the demo.

app.config.ts
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent(() => {
      const connection = injectCockpitRuntimeConnection();
      if (connection.adapter !== 'ag-ui') {
        throw new Error('incompatible runtime');
      }
      return {
        url: connection.url,
      };
    }),
  ],
};

Your own application does not need the factory. Pass the URL of your AG-UI endpoint directly:

provideAgent({
  url: 'https://your-backend.example.com/agent',
});

Rendering the streamed expense

The adapter applies every STATE_SNAPSHOT and STATE_DELTA to one store and projects it as agent.state(). The component reads the expense key from that signal and holds the draft back until it has a vendor, which suppresses the panel until the vendor key exists, so it does not flash a half-written draft while the arguments stream. The panel itself is ordinary Angular template code over that signal.

microsoft-agent-framework.component.ts — reading shared state
/** Shared state streamed from the backend (STATE_SNAPSHOT / STATE_DELTA). */
protected readonly expense = computed(() => {
  // Example apps compile lib source with strict:false — cast at the read site.
  const state = this.agent.state() as { expense?: ExpenseDraft } | undefined;
  const e = state?.expense;
  return e && e.vendor !== undefined ? e : undefined;
});

Approving the tool call

<chat-approval-card> renders the pending approval as a modal dialog and emits 'approve' or 'cancel'. Its body template is yours to write, and this one reads the pending tool call out of agent.interrupt(), whose value the adapter sets to { interrupts: [...], runId }, and each entry's metadata.agent_framework.function_call carries the tool name and its parsed arguments.

microsoft-agent-framework.component.ts — the approval wiring
/**
 * The pending approval request from the protocol-standard interrupt
 * outcome. The reducer stores it as `{ interrupts: [...], runId }`; each
 * entry's `metadata.agent_framework.function_call` carries the tool name
 * and parsed arguments.
 */
private readonly approvalCall = computed(() => {
  const value = this.agent.interrupt?.()?.value as { interrupts?: unknown[] } | undefined;
  const first = value?.interrupts?.[0] as
    | { metadata?: { agent_framework?: { function_call?: { name?: string; arguments?: unknown } } } }
    | undefined;
  return first?.metadata?.agent_framework?.function_call;
});
 
protected readonly approvalToolName = computed(() => this.approvalCall()?.name ?? 'a tool call');
 
protected readonly approvalExpense = computed(() => {
  const args = this.approvalCall()?.arguments as { expense?: ExpenseDraft } | undefined;
  return args?.expense;
});
 
protected send(text: string): void {
  void this.agent.submit({ message: text });
}
 
protected onAction(action: ChatApprovalAction): void {
  if (action === 'approve') {
    void this.agent.submit({ resume: { approved: true } });
  } else if (action === 'cancel') {
    void this.agent.submit({ resume: { approved: false } });
  }
}

Both branches call the same neutral agent.submit({ resume }), and the adapter derives the wire shape from how the interrupt arrived.

What the integration demonstrates

SurfaceStatusHow
MessagesSupportedStreamed assistant text from Agent in agent-framework-core.
Tool callsSupportedlookup_expense_policy executes server-side with no pause.
Shared stateSupportedpredict_state_config streams a tool argument into frontend state.
InterruptsSupportedsubmit_expense declares approval_mode="always_require".
SubagentsSupported (streaming)An in-tree queue-merge emitter at the bridge boundary streams the specialist's deltas as SUBAGENT_* events.

Together with Mastra this is the most complete third-party row in the measured matrix: all five surfaces are green.

Why the state updates are deltas

Predictive state is the interesting part of this runtime. The bridge does not wait for the tool call to finish before telling the client about it: it emits real STATE_DELTA patches as the argument grows, and the adapter applies each patch to the state it already holds. The user watches the expense fill in before the tool has been called, let alone approved.

That is not true of every runtime. On AWS Strands shared state is snapshot-only and opt-in per tool, so every update replaces the whole state object and each hook has to return it complete. Here the adapter reassembles nothing.

How the interrupt travels

Microsoft Agent Framework signals an interrupt only through the protocol-standard RUN_FINISHED outcome, { type: 'interrupt', interrupts: [...] }. It never uses the LangGraph bridge's CUSTOM event named on_interrupt. The adapter detects either convention, and within a single run the first signal wins.

The resume payload is shaped the same way. Because the interrupt arrived as an outcome, submit({ resume }) goes out as the protocol-standard top-level resume array, one { interruptId, status, payload } entry per pending interrupt — and this bridge expects an entry for every pending interrupt, not only the one the user answered. You pass one neutral resume value and the adapter builds that array.

What's Next

Looking for something specific?