State Streaming
Stream partial agent state updates to the UI while a tool call is still running.
"""Claude Agent SDK (Python) -- sales assistant with weather, HITL, and generative UI.Implements the AG-UI protocol directly using the Anthropic Python SDK.All demo routes share this single agent instance served by agent_server.py."""from __future__ import annotationsimport jsonimport osimport randomimport tracebackfrom collections.abc import AsyncIteratorfrom textwrap import dedentfrom typing import Anyimport anthropicfrom ag_ui.core import ( EventType, Message, RunAgentInput, RunFinishedEvent, RunStartedEvent, StateSnapshotEvent, TextMessageContentEvent, TextMessageEndEvent, TextMessageStartEvent, ToolCallArgsEvent, ToolCallEndEvent, ToolCallResultEvent, ToolCallStartEvent,)from ag_ui.encoder import EventEncoderfrom dotenv import load_dotenvfrom fastapi import FastAPI, Requestfrom fastapi.middleware.cors import CORSMiddlewarefrom fastapi.responses import StreamingResponsefrom pydantic import BaseModelfrom starlette.middleware.base import BaseHTTPMiddlewarefrom starlette.responses import JSONResponsefrom agents.claude_agent_sdk_adapter import ( normalize_claude_model, run_with_claude_agent_sdk, should_use_claude_agent_sdk,)from agents._anthropic_message_safety import sanitize_unresolved_tool_uses# Serve /health via middleware so it short-circuits BEFORE route resolution.# Any later catch-all mount at "/" (whether added here or by a downstream# adapter) would shadow a plain `@app.get("/health")` decorator. Middleware# runs above routing so the health endpoint stays reachable regardless.class HealthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): if request.url.path == "/health" and request.method == "GET": return JSONResponse({"status": "ok"}) return await call_next(request)load_dotenv()DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4.6"# Import shared tool implementations (via tools symlink -> ../../shared/python/tools)from tools import ( get_weather_impl, query_data_impl, manage_sales_todos_impl, get_sales_todos_impl, schedule_meeting_impl, search_flights_impl, build_a2ui_operations_from_tool_call, RENDER_A2UI_TOOL_SCHEMA,)from tools.types import Flight# ============# Tool schemas# ============TOOLS: list[dict[str, Any]] = [ { "name": "get_weather", "description": ( "Get current weather for a location. " "Use this to render the frontend weather card." ), "input_schema": { "type": "object", "properties": { "location": { "type": "string", "description": "The city or region to get weather for.", }, }, "required": ["location"], }, }, { "name": "query_data", "description": ( "Query the financial database for chart data. " "Always call before showing a chart or graph." ), "input_schema": { "type": "object", "properties": { "query": { "type": "string", "description": "Natural language query for financial data.", }, }, "required": ["query"], }, }, { "name": "manage_sales_todos", "description": ( "Replace the entire list of sales todos with the provided values. " "Always include every todo you want to keep." ), "input_schema": { "type": "object", "properties": { "todos": { "type": "array", "items": { "type": "object", "properties": { "id": {"type": "string"}, "title": {"type": "string"}, "stage": { "type": "string", "enum": [ "prospect", "qualified", "proposal", "negotiation", "closed-won", "closed-lost", ], }, "value": {"type": "number"}, "dueDate": {"type": "string"}, "assignee": {"type": "string"}, "completed": {"type": "boolean"}, }, "required": [ "title", "stage", "value", "dueDate", "assignee", "completed", ], }, "description": "The complete list of sales todos.", }, }, "required": ["todos"], }, }, { "name": "get_sales_todos", "description": "Get the current sales pipeline todos.", "input_schema": { "type": "object", "properties": {}, }, }, { "name": "schedule_meeting", "description": ( "Schedule a meeting with the user. Requires human approval. " "Call this when the user wants to schedule or book a meeting." ), "input_schema": { "type": "object", "properties": { "reason": { "type": "string", "description": "Reason for the meeting.", }, }, "required": ["reason"], }, }, { "name": "generate_task_steps", "description": ( "Propose a list of steps for the user to review and approve. " "Used for human-in-the-loop task planning. " "Always call this tool when the user asks you to plan something." ), "input_schema": { "type": "object", "properties": { "steps": { "type": "array", "items": { "type": "object", "properties": { "description": {"type": "string"}, "status": { "type": "string", "enum": ["enabled", "disabled", "executing"], }, }, "required": ["description", "status"], }, "description": "The ordered list of steps for the user to review.", } }, "required": ["steps"], }, }, { "name": "change_background", "description": ( "Change the background color or gradient of the chat UI. " "ONLY call this when the user explicitly asks to change the background." ), "input_schema": { "type": "object", "properties": { "background": { "type": "string", "description": "CSS background value. Prefer gradients.", } }, "required": ["background"], }, }, { "name": "search_flights", "description": ( "Search for flights and display the results as rich A2UI cards. " "Return exactly 2 flights. Each flight must have: airline, airlineLogo, " "flightNumber, origin, destination, date, departureTime, arrivalTime, " "duration, status, statusColor, price, currency. " "For airlineLogo use: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128" ), "input_schema": { "type": "object", "properties": { "flights": { "type": "array", "items": { "type": "object", "properties": { "airline": {"type": "string"}, "airlineLogo": {"type": "string"}, "flightNumber": {"type": "string"}, "origin": {"type": "string"}, "destination": {"type": "string"}, "date": {"type": "string"}, "departureTime": {"type": "string"}, "arrivalTime": {"type": "string"}, "duration": {"type": "string"}, "status": {"type": "string"}, "statusColor": {"type": "string"}, "price": {"type": "string"}, "currency": {"type": "string"}, }, }, "description": "List of flight objects to display.", }, }, "required": ["flights"], }, }, { "name": "generate_a2ui", "description": ( "Generate dynamic A2UI components based on the conversation. " "A secondary LLM designs the UI schema and data." ), "input_schema": { "type": "object", "properties": { "context": { "type": "string", "description": "Conversation context to generate UI for.", }, }, "required": ["context"], }, },]MANAGE_TODOS_TOOL_SCHEMA: dict[str, Any] = { "name": "manage_todos", "description": ( "Replace the beautiful-chat task manager todo list. Always include every " "todo that should remain visible." ), "input_schema": { "type": "object", "properties": { "todos": { "type": "array", "description": "The complete task-manager todo list.", "items": { "type": "object", "properties": { "id": {"type": "string"}, "title": {"type": "string"}, "description": {"type": "string"}, "emoji": {"type": "string"}, "status": { "type": "string", "enum": ["pending", "completed"], }, }, "required": ["title", "description", "emoji", "status"], }, }, }, "required": ["todos"], },}GET_TODOS_TOOL_SCHEMA: dict[str, Any] = { "name": "get_todos", "description": "Get the current beautiful-chat task manager todo list.", "input_schema": { "type": "object", "properties": {}, },}BEAUTIFUL_CHAT_TOOLS = [ *TOOLS, MANAGE_TODOS_TOOL_SCHEMA, GET_TODOS_TOOL_SCHEMA,]# Dedicated demo tool sets. These demos register render-only frontend# surfaces, so their executable tools must stay backend-owned.HEADLESS_GET_WEATHER_TOOL_SCHEMA = TOOLS[0]HEADLESS_GET_STOCK_PRICE_TOOL_SCHEMA: dict[str, Any] = { "name": "get_stock_price", "description": ( "Get a mock current price for a stock ticker. Returns ticker, " "price_usd, and change_pct." ), "input_schema": { "type": "object", "properties": { "ticker": { "type": "string", "description": "Stock ticker symbol, e.g. AAPL.", }, }, "required": ["ticker"], },}SEARCH_FLIGHTS_SIMPLE_TOOL_SCHEMA: dict[str, Any] = { "name": "search_flights", "description": ( "Search for mock flights between two airports. Returns origin, " "destination, and a list of flights." ), "input_schema": { "type": "object", "properties": { "origin": {"type": "string", "description": "Origin airport code."}, "destination": { "type": "string", "description": "Destination airport code.", }, }, "required": ["origin", "destination"], },}ROLL_D20_TOOL_SCHEMA: dict[str, Any] = { "name": "roll_d20", "description": ( "Roll a 20-sided die. Accepts an optional value for deterministic demos." ), "input_schema": { "type": "object", "properties": { "value": { "type": "number", "description": "Optional fixed result.", }, }, },}SET_STEPS_TOOL_SCHEMA: dict[str, Any] = { "name": "set_steps", "description": ( "Publish the current plan and step statuses. The provided list replaces " "the previous state." ), "input_schema": { "type": "object", "properties": { "steps": { "type": "array", "items": { "type": "object", "properties": { "id": {"type": "string"}, "title": {"type": "string"}, "status": { "type": "string", "enum": ["pending", "in_progress", "completed"], }, }, "required": ["id", "title", "status"], }, }, }, "required": ["steps"], },}WRITE_DOCUMENT_TOOL_SCHEMA: dict[str, Any] = { "name": "write_document", "description": ( "Write a document into shared agent state. Use for poems, emails, " "summaries, explainers, and other drafted text." ), "input_schema": { "type": "object", "properties": { "document": { "type": "string", "description": "The full document text to render in shared state.", }, }, "required": ["document"], },}SHARED_STATE_STREAMING_TOOLS = [WRITE_DOCUMENT_TOOL_SCHEMA]SHARED_STATE_STREAMING_SYSTEM_PROMPT = dedent(""" You are a collaborative writing assistant. Whenever the user asks you to write, draft, or revise text, call `write_document` with the full content in the `document` argument. Do not paste the document into the chat message directly; the UI renders shared state.""").strip()def _decode_partial_json_string(raw: str) -> str | None: """Decode the largest safe prefix of a streamed JSON string literal body.""" while raw.endswith("\\"): raw = raw[:-1] unicode_start = raw.rfind("\\u") if unicode_start != -1: hex_digits = raw[unicode_start + 2 :] if len(hex_digits) < 4 or any( c not in "0123456789abcdefABCDEF" for c in hex_digits ): raw = raw[:unicode_start] try: return json.loads(f'"{raw}"') except json.JSONDecodeError: return Nonedef _partial_json_string_property(source: str, key: str) -> str | None: key_literal = json.dumps(key) key_pos = source.find(key_literal) if key_pos < 0: return None colon_pos = source.find(":", key_pos + len(key_literal)) if colon_pos < 0: return None value_start = colon_pos + 1 while value_start < len(source) and source[value_start].isspace(): value_start += 1 if value_start >= len(source) or source[value_start] != '"': return None raw_chars: list[str] = [] escaped = False for char in source[value_start + 1 :]: if escaped: raw_chars.append("\\" + char) escaped = False continue if char == "\\": escaped = True continue if char == '"': break raw_chars.append(char) if escaped: raw_chars.append("\\") return _decode_partial_json_string("".join(raw_chars))HEADLESS_COMPLETE_TOOLS = [ HEADLESS_GET_WEATHER_TOOL_SCHEMA, HEADLESS_GET_STOCK_PRICE_TOOL_SCHEMA, { "name": "get_revenue_chart", "description": ( "Return a mock six-month revenue trend chart. Use this when the " "user asks for revenue, sales, or trend charts." ), "input_schema": {"type": "object", "properties": {}}, },]TOOL_RENDERING_TOOLS = [ HEADLESS_GET_WEATHER_TOOL_SCHEMA, HEADLESS_GET_STOCK_PRICE_TOOL_SCHEMA, SEARCH_FLIGHTS_SIMPLE_TOOL_SCHEMA, ROLL_D20_TOOL_SCHEMA,]GEN_UI_AGENT_TOOLS = [SET_STEPS_TOOL_SCHEMA]HEADLESS_COMPLETE_SYSTEM_PROMPT = dedent(""" You are a helpful, concise assistant wired into a headless chat surface. Routing rules: - If the user asks about weather, call `get_weather`. - If the user asks about a stock or ticker, call `get_stock_price`. - If the user asks for a revenue, sales, or trend chart, call `get_revenue_chart`. - If the user asks you to highlight, flag, or mark a note, call the frontend `highlight_note` tool with text and a color. - Otherwise, reply in plain text. After a tool returns, write one short sentence summarizing the result. Never fabricate data a tool could provide.""").strip()TOOL_RENDERING_SYSTEM_PROMPT = dedent(""" You are a helpful, concise assistant in a demo that renders every tool call as a branded card. Pick the right backend tool for each user question. Routing rules: - Weather questions: call `get_weather`. - Flight searches: call `search_flights` with origin and destination codes. - Stock/ticker questions: call `get_stock_price`. - A d20 roll: call `roll_d20`. If the user asks for several rolls, call it once per roll. - "Chain a few tools": call get_weather, search_flights, and roll_d20. After the tools return, write one short sentence summarizing the results. Never fabricate data a tool could provide.""").strip()GEN_UI_AGENT_SYSTEM_PROMPT = dedent(""" You are an agentic planner. For each user request, follow this exact sequence: 1. Plan exactly 3 concrete steps and call `set_steps` once with all three steps at status "pending". 2. Move step 1 to "in_progress", then "completed", calling `set_steps` after each transition. 3. Move step 2 to "in_progress", then "completed", calling `set_steps` after each transition. 4. Move step 3 to "in_progress", then "completed", calling `set_steps` after each transition. 5. Send one final conversational assistant message summarizing the plan. Never call set_steps in parallel. Always pass the full step list.""").strip()SYSTEM_PROMPT = dedent(""" You are a helpful sales assistant that manages a sales pipeline, discusses weather, queries financial data, schedules meetings, and helps with planning. Sales pipeline management: - The current list of sales todos is provided in the conversation context. - When you add, remove, or update todos, call `manage_sales_todos` with the FULL list. - CRITICAL: When asked to "add" a todo, include ALL existing todos + the new one. - When asked to "remove" a todo, include everything EXCEPT the removed one. Tool usage: - `get_weather`: only call when the user explicitly asks about weather. - `query_data`: call when the user asks about financial data, charts, or graphs. - `manage_sales_todos`: call to update the sales pipeline. - `get_sales_todos`: call to retrieve current sales pipeline. - `schedule_meeting`: call when the user wants to schedule a meeting. - `generate_task_steps`: call when the user asks you to plan something step-by-step. Wait for approval/rejection before continuing with the plan. - `change_background`: only call when user explicitly asks to change the background. - `search_flights`: call when the user asks about flights. Generate 2 realistic flights. - `generate_a2ui`: call when the user asks for a dashboard or dynamic UI. After executing tools, provide a brief summary of what changed. Keep responses concise and friendly.""").strip()BEAUTIFUL_CHAT_SYSTEM_PROMPT = dedent(""" You are a helpful CopilotKit demo assistant. Use tools to render rich UI instead of describing UI in prose. Routing rules: - Charts: call `query_data` first when the user asks for financial data, then use the frontend chart tool requested by the user. - Flights: call `search_flights` with exactly two complete flight objects so the A2UI flight cards can render. - Dashboards: call `query_data`, then `generate_a2ui`. - Todos: call `enableAppMode` first, then `manage_todos` with the full todo list. - Meetings and theme changes are frontend tools; call the matching frontend tool when requested. After tools complete, summarize the result in one short sentence.""").strip()# ===========# AG-UI runner# ===========class AgentState(BaseModel): todos: list[dict] = [] steps: list[dict] = [] document: str = ""def _coerce_beautiful_chat_todos(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] todos: list[dict[str, Any]] = [] for raw_todo in value: if not isinstance(raw_todo, dict): continue todos.append( { "id": str(raw_todo.get("id") or f"todo-{random.randint(1000, 9999)}"), "title": str(raw_todo.get("title") or ""), "description": str(raw_todo.get("description") or ""), "emoji": str(raw_todo.get("emoji") or "*"), "status": ( "completed" if raw_todo.get("status") == "completed" else "pending" ), } ) return todosdef _get_stock_price_impl(ticker: str) -> dict[str, Any]: return { "ticker": ticker.upper(), "price_usd": 189.42, "change_pct": 1.27, }def _search_flights_by_route_impl(origin: str, destination: str) -> dict[str, Any]: return { "origin": origin, "destination": destination, "flights": [ { "airline": "United", "flight": "UA231", "depart": "08:15", "arrive": "16:45", "price_usd": 348, }, { "airline": "Delta", "flight": "DL412", "depart": "11:20", "arrive": "19:50", "price_usd": 312, }, { "airline": "JetBlue", "flight": "B6722", "depart": "17:05", "arrive": "01:35", "price_usd": 289, }, ], }def _execute_tool( name: str, tool_input: dict[str, Any], state: AgentState, conversation_messages: list[dict[str, Any]] | None = None,) -> tuple[str, AgentState | None]: """Execute backend tools and return (result_text, new_state_or_None).""" if name == "get_weather": return json.dumps(get_weather_impl(tool_input["location"])), None if name == "query_data": return json.dumps(query_data_impl(tool_input["query"])), None if name == "manage_todos": state.todos = _coerce_beautiful_chat_todos(tool_input.get("todos")) return json.dumps({"status": "updated", "count": len(state.todos)}), state if name == "get_todos": return json.dumps(_coerce_beautiful_chat_todos(state.todos)), None if name == "manage_sales_todos": result = manage_sales_todos_impl(tool_input["todos"]) state.todos = [dict(t) for t in result] return json.dumps({"status": "updated", "count": len(result)}), state if name == "get_sales_todos": return json.dumps( get_sales_todos_impl(state.todos if state.todos else None) ), None if name == "schedule_meeting": return json.dumps(schedule_meeting_impl(tool_input["reason"])), None if name == "generate_task_steps": # Frontend HITL tool -- backend just acknowledges; UI handles the interaction steps = tool_input.get("steps", []) return f"Presented {len(steps)} steps for review.", None if name == "change_background": # Frontend tool -- backend just acknowledges return f"Background change requested: {tool_input.get('background', '')}", None if name == "search_flights": if "flights" in tool_input: flights_data = tool_input.get("flights", []) typed_flights = [Flight(**f) for f in flights_data] result = search_flights_impl(typed_flights) return json.dumps(result), None return json.dumps( _search_flights_by_route_impl( str(tool_input.get("origin", "")), str(tool_input.get("destination", "")), ) ), None if name == "get_stock_price": return json.dumps( _get_stock_price_impl(str(tool_input.get("ticker", ""))) ), None if name == "get_revenue_chart": return json.dumps( { "title": "Revenue trend", "subtitle": "Last six months, USD thousands", "data": [ {"label": "Jan", "value": 42}, {"label": "Feb", "value": 48}, {"label": "Mar", "value": 53}, {"label": "Apr", "value": 57}, {"label": "May", "value": 63}, {"label": "Jun", "value": 71}, ], } ), None if name == "roll_d20": value = tool_input.get("value") return json.dumps( { "value": int(value) if isinstance(value, (int, float)) else random.randint(1, 20) } ), None if name == "set_steps": steps = tool_input.get("steps", []) state.steps = [dict(step) for step in steps if isinstance(step, dict)] return json.dumps({"status": "updated", "count": len(state.steps)}), state if name == "write_document": document = str(tool_input.get("document", "")) state.document = document return json.dumps({"status": "updated", "length": len(document)}), state if name == "generate_a2ui": context = tool_input.get("context", "") client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY", "")) render_tool_schema = { "name": RENDER_A2UI_TOOL_SCHEMA["name"], "description": RENDER_A2UI_TOOL_SCHEMA["description"], "input_schema": RENDER_A2UI_TOOL_SCHEMA["parameters"], } llm_messages: list[dict[str, Any]] = [] # Pass conversation messages to the secondary LLM for context if conversation_messages: llm_messages.extend( sanitize_unresolved_tool_uses( conversation_messages, ) ) else: llm_messages.append( { "role": "user", "content": "Generate a dynamic A2UI dashboard based on the conversation.", } ) response = client.messages.create( model=normalize_claude_model( os.getenv("ANTHROPIC_MODEL", DEFAULT_ANTHROPIC_MODEL) ), max_tokens=4096, system=context or "Generate a useful dashboard UI.", messages=llm_messages, tools=[render_tool_schema], tool_choice={"type": "tool", "name": "render_a2ui"}, ) for block in response.content: if ( getattr(block, "type", None) == "tool_use" and block.name == "render_a2ui" ): a2ui_result = build_a2ui_operations_from_tool_call(dict(block.input)) return json.dumps(a2ui_result), None return json.dumps({"error": "LLM did not call render_a2ui"}), None return f"Unknown tool: {name}", Nonedef _build_frontend_tools(input_data: RunAgentInput) -> list[dict[str, Any]]: """Extract frontend-defined tools from the AG-UI request. The CopilotKit runtime forwards frontend tool definitions (registered via ``useFrontendTool``, ``useHumanInTheLoop``, etc.) in ``input_data.tools``. We convert them to the Anthropic ``tools`` schema so the LLM can call them. The runtime intercepts the resulting tool-call events and routes them to the frontend for resolution. """ out: list[dict[str, Any]] = [] for t in input_data.tools or []: name = getattr(t, "name", None) or ( t.get("name") if isinstance(t, dict) else None ) description = getattr(t, "description", None) or ( t.get("description", "") if isinstance(t, dict) else "" ) parameters = getattr(t, "parameters", None) or ( t.get("parameters", {}) if isinstance(t, dict) else {} ) if not name: continue out.append( { "name": name, "description": description or "", "input_schema": parameters or {"type": "object", "properties": {}}, } ) return outasync def run_agent( input_data: RunAgentInput, *, system_prompt_override: str | None = None, disable_tools: bool = False, preprocess_user_parts: Any = None, tools_override: list[dict[str, Any]] | None = None, frontend_tool_names_allowlist: set[str] | None = None, latest_user_message_only: bool = False,) -> AsyncIterator[str]: """Run the Claude agent and yield AG-UI SSE events. Keyword arguments let dedicated demo endpoints reuse this streaming loop with targeted overrides: - ``system_prompt_override`` — replace the shared ``SYSTEM_PROMPT`` (e.g. BYOC demos emit a JSON envelope, so the sales-assistant prompt is irrelevant). - ``disable_tools`` — run the model with no tool schemas. Useful for BYOC / pure-text demos where tool calls would derail the output. - ``preprocess_user_parts`` — a ``callable(part) -> part`` applied to each content part of every user message before they are sent to Claude. Used by the multimodal demo to convert AG-UI ``image``/``document`` parts into Claude's Messages API shape (``{"type": "image", "source": {...}}``) and to flatten PDFs to text via ``pypdf``. """ encoder = EventEncoder() client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", "")) # Extract state state = AgentState() if input_data.state and isinstance(input_data.state, dict): state = AgentState(**input_data.state) # Convert AG-UI messages to Anthropic format. When a preprocessor is # supplied we preserve the structured content list (image blocks, # document text, etc.) — otherwise we collapse to a flat string for # the text-only happy path used by most demos. # # AG-UI delivers three message roles: # - "user" → plain user text # - "assistant" → assistant text + optional tool_use blocks # - "tool" → tool result from a resolved frontend tool # # Anthropic's Messages API represents tool results as a "user" role # message with content blocks of type "tool_result". We must convert # AG-UI "tool" messages into that shape so the LLM sees the resolved # result and aimock's ``hasToolResult`` matcher fires correctly. messages: list[dict[str, Any]] = [] for msg in input_data.messages or []: role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) # Handle tool result messages from AG-UI (resolved frontend tools). # Convert to Anthropic's format: role="user" with tool_result blocks. if role == "tool": tool_call_id = getattr(msg, "tool_call_id", None) or ( getattr(msg, "toolCallId", None) ) raw_content = getattr(msg, "content", None) result_text = "" if isinstance(raw_content, str): result_text = raw_content elif isinstance(raw_content, list): parts = [] for part in raw_content: if hasattr(part, "text"): parts.append(part.text) elif isinstance(part, dict) and "text" in part: parts.append(part["text"]) parts_text = "".join(parts) if parts_text: result_text = parts_text else: result_text = json.dumps(raw_content) if tool_call_id: # Anthropic expects the assistant message containing the # tool_use to precede this tool_result message. The runtime # ensures message ordering, so we just need to emit the # tool_result in the right shape. messages.append( { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": tool_call_id, "content": result_text, } ], } ) continue if role not in ("user", "assistant"): continue raw_content = getattr(msg, "content", None) if ( preprocess_user_parts is not None and role == "user" and isinstance(raw_content, list) ): converted_parts: list[Any] = [] for part in raw_content: # AG-UI emits pydantic models; normalise to a plain dict # before handing to the converter so the demo-specific # code can rely on ``.get(...)`` semantics. if hasattr(part, "model_dump"): part_dict = part.model_dump() elif isinstance(part, dict): part_dict = part else: part_dict = part converted = preprocess_user_parts(part_dict) if converted is not None: converted_parts.append(converted) if converted_parts: messages.append({"role": role, "content": converted_parts}) continue # For assistant messages, check if there are tool calls (AG-UI's # AssistantMessage stores them in `tool_calls`, not in `content`). # Anthropic requires tool_use blocks in the assistant content so # the subsequent tool_result can pair with them. if role == "assistant": msg_tool_calls = getattr(msg, "tool_calls", None) text_content = "" if isinstance(raw_content, str): text_content = raw_content elif isinstance(raw_content, list): for part in raw_content: if hasattr(part, "text"): text_content += part.text elif isinstance(part, dict) and "text" in part: text_content += part["text"] if msg_tool_calls: content_blocks: list[dict[str, Any]] = [] if text_content: content_blocks.append({"type": "text", "text": text_content}) for tc in msg_tool_calls: # AG-UI ToolCall: {id, function: {name, arguments}} tc_id = getattr(tc, "id", None) or ( tc.get("id") if isinstance(tc, dict) else None ) func = getattr(tc, "function", None) or ( tc.get("function") if isinstance(tc, dict) else None ) if func: tc_name = getattr(func, "name", None) or ( func.get("name") if isinstance(func, dict) else "unknown" ) tc_args_str = getattr(func, "arguments", None) or ( func.get("arguments", "{}") if isinstance(func, dict) else "{}" ) else: tc_name = "unknown" tc_args_str = "{}" try: tc_args = ( json.loads(tc_args_str) if isinstance(tc_args_str, str) else tc_args_str ) except json.JSONDecodeError: tc_args = {} content_blocks.append( { "type": "tool_use", "id": tc_id or "unknown", "name": tc_name, "input": tc_args, } ) messages.append({"role": "assistant", "content": content_blocks}) continue elif text_content: messages.append({"role": "assistant", "content": text_content}) continue # Fall through to the generic handler if nothing matched content = "" if isinstance(raw_content, str): content = raw_content elif isinstance(raw_content, list): parts = [] for part in raw_content: if hasattr(part, "text"): parts.append(part.text) elif isinstance(part, dict) and "text" in part: parts.append(part["text"]) content = "".join(parts) if content: messages.append({"role": role, "content": content}) sdk_input_data = input_data if latest_user_message_only: latest_user_message = next( (m for m in reversed(messages) if m.get("role") == "user"), None, ) messages = [latest_user_message] if latest_user_message else [] latest_input_message = next( ( m for m in reversed(input_data.messages or []) if (m.role.value if hasattr(m.role, "value") else str(m.role)) == "user" ), None, ) sdk_messages = [latest_input_message] if latest_input_message else [] if hasattr(input_data, "model_copy"): sdk_input_data = input_data.model_copy(update={"messages": sdk_messages}) else: # pragma: no cover - compatibility with older pydantic models sdk_input_data = input_data.copy(update={"messages": sdk_messages}) # Inject sales pipeline state into system prompt if state exists if system_prompt_override is not None: system = system_prompt_override else: system = SYSTEM_PROMPT if state.todos: todos_json = json.dumps(state.todos, indent=2) system = f"{SYSTEM_PROMPT}\n\nCurrent sales pipeline:\n{todos_json}" context_entries = getattr(input_data, "context", None) or [] if context_entries: context_lines: list[str] = [] for entry in context_entries: if isinstance(entry, dict): description = entry.get("description") value = entry.get("value") else: description = getattr(entry, "description", None) value = getattr(entry, "value", None) if description: context_lines.append(f"{description}: {value}") if context_lines: system = f"{system}\n\nContext:\n" + "\n".join(context_lines) sdk_backend_tools = ( [] if disable_tools else (tools_override if tools_override is not None else TOOLS) ) sdk_frontend_tools = [] if disable_tools else _build_frontend_tools(input_data) if frontend_tool_names_allowlist is not None: sdk_frontend_tools = [ t for t in sdk_frontend_tools if t["name"] in frontend_tool_names_allowlist ] sdk_frontend_tool_names = {t["name"] for t in sdk_frontend_tools} if should_use_claude_agent_sdk( input_data=input_data, backend_tools=sdk_backend_tools, frontend_tool_names=sdk_frontend_tool_names, preprocess_user_parts=preprocess_user_parts, ): async for chunk in run_with_claude_agent_sdk( sdk_input_data, system_prompt=system, tools=sdk_backend_tools, state=state, model=os.getenv("ANTHROPIC_MODEL", DEFAULT_ANTHROPIC_MODEL), execute_tool=_execute_tool, ): yield chunk return thread_id = input_data.thread_id or "default" run_id = input_data.run_id or "run-1" yield encoder.encode( RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id) ) # Agentic loop -- keep calling Claude until no more tool calls while True: response_text = "" tool_calls: list[dict[str, Any]] = [] msg_id = f"msg-{run_id}-{len(messages)}" yield encoder.encode( TextMessageStartEvent( type=EventType.TEXT_MESSAGE_START, message_id=msg_id, role="assistant", ) ) # Build the combined tools list: backend TOOLS + any frontend- # defined tools forwarded by the CopilotKit runtime in # input_data.tools. Frontend tools (registered via useFrontendTool, # useHumanInTheLoop, etc.) are included so the LLM can call them; # the runtime intercepts the resulting events and routes them to # the frontend for resolution. Backend tools are executed locally. backend_tools = tools_override if tools_override is not None else TOOLS backend_tool_names = {t["name"] for t in backend_tools} frontend_tools = _build_frontend_tools(input_data) if frontend_tool_names_allowlist is not None: frontend_tools = [ t for t in frontend_tools if t["name"] in frontend_tool_names_allowlist ] # Merge: backend tools first, then frontend tools that don't # shadow a backend tool (frontend wins when names collide, because # the frontend registration means the runtime should intercept). frontend_tool_names = {t["name"] for t in frontend_tools} combined_tools: list[dict[str, Any]] = [] for t in backend_tools: if t["name"] not in frontend_tool_names: combined_tools.append(t) combined_tools.extend(frontend_tools) stream_kwargs: dict[str, Any] = { "model": normalize_claude_model( os.getenv("ANTHROPIC_MODEL", DEFAULT_ANTHROPIC_MODEL) ), "max_tokens": 4096, "system": system, "messages": messages, } if not disable_tools: stream_kwargs["tools"] = combined_tools # type: ignore[assignment] try: async with client.messages.stream(**stream_kwargs) as stream: current_tool_id: str | None = None current_tool_name: str | None = None current_tool_args = "" last_streamed_document = state.document async for event in stream: etype = type(event).__name__ if etype == "RawContentBlockStartEvent": block = event.content_block # type: ignore[attr-defined] if block.type == "text": pass # streaming text chunks follow elif block.type == "tool_use": current_tool_id = block.id current_tool_name = block.name current_tool_args = "" yield encoder.encode( ToolCallStartEvent( type=EventType.TOOL_CALL_START, tool_call_id=current_tool_id, tool_call_name=current_tool_name, parent_message_id=msg_id, ) ) elif etype == "RawContentBlockDeltaEvent": delta = event.delta # type: ignore[attr-defined] if delta.type == "text_delta": response_text += delta.text yield encoder.encode( TextMessageContentEvent( type=EventType.TEXT_MESSAGE_CONTENT, message_id=msg_id, delta=delta.text, ) ) elif delta.type == "input_json_delta": current_tool_args += delta.partial_json yield encoder.encode( ToolCallArgsEvent( type=EventType.TOOL_CALL_ARGS, tool_call_id=current_tool_id or "", delta=delta.partial_json, ) ) if current_tool_name == "write_document": streamed_document = _partial_json_string_property( current_tool_args, "document", ) if ( streamed_document is not None and streamed_document != last_streamed_document ): state.document = streamed_document last_streamed_document = streamed_document yield encoder.encode( StateSnapshotEvent( type=EventType.STATE_SNAPSHOT, snapshot=state.model_dump(), ) ) elif etype in ( "RawContentBlockStopEvent", "ParsedContentBlockStopEvent", ): if current_tool_id and current_tool_name: yield encoder.encode( ToolCallEndEvent( type=EventType.TOOL_CALL_END, tool_call_id=current_tool_id, ) ) try: parsed_args = ( json.loads(current_tool_args) if current_tool_args else {} ) except json.JSONDecodeError: parsed_args = {} tool_calls.append( { "id": current_tool_id, "name": current_tool_name, "input": parsed_args, } ) current_tool_id = None current_tool_name = None current_tool_args = "" except Exception: # Surface the error as visible text in the chat so D5 # probes see a non-empty assistant response instead of a # silent broken SSE stream. Full traceback is logged # server-side by FastAPI's exception handler. err_text = f"Agent error: {traceback.format_exc()}" yield encoder.encode( TextMessageContentEvent( type=EventType.TEXT_MESSAGE_CONTENT, message_id=msg_id, delta=err_text, ) ) yield encoder.encode( TextMessageEndEvent( type=EventType.TEXT_MESSAGE_END, message_id=msg_id, ) ) # No tool calls -- we're done if not tool_calls: break # Separate tool calls into backend (locally executed) and frontend # (deferred to the CopilotKit runtime / frontend for resolution). # A tool whose name was registered on the frontend (present in # frontend_tool_names) is a frontend tool even if the backend also # defines it — the frontend registration takes precedence because # hooks like useHumanInTheLoop rely on intercepting the tool call. # Add assistant turn with tool calls to message history assistant_content: list[dict[str, Any]] = [] if response_text: assistant_content.append({"type": "text", "text": response_text}) for tc in tool_calls: assistant_content.append( { "type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc["input"], } ) messages.append({"role": "assistant", "content": assistant_content}) # Execute backend tools and build tool-result turn. Frontend tools are # intentionally left unresolved here so the CopilotKit runtime can # intercept them and re-invoke the agent after the browser handler runs. tool_results: list[dict[str, Any]] = [] has_frontend_tool = False for tc in tool_calls: if tc["name"] in frontend_tool_names: has_frontend_tool = True continue result_text, new_state = _execute_tool( tc["name"], tc["input"], state, conversation_messages=messages ) if new_state is not None: state = new_state yield encoder.encode( StateSnapshotEvent( type=EventType.STATE_SNAPSHOT, snapshot=state.model_dump(), ) ) yield encoder.encode( ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, tool_call_id=tc["id"], message_id=f"{msg_id}-tool-result-{tc['id']}", content=result_text, ) ) tool_results.append( { "type": "tool_result", "tool_use_id": tc["id"], "content": result_text, } ) if tool_results: messages.append({"role": "user", "content": tool_results}) if has_frontend_tool: # At least one tool call targets a frontend tool. Break the # agentic loop after any backend siblings have been resolved; the # runtime owns the frontend continuation from here. break yield encoder.encode( RunFinishedEvent( type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id ) )def create_app() -> FastAPI: """Create the FastAPI app with AG-UI endpoint.""" # Local import to avoid a top-level ``agents._header_forwarding`` # dependency in this module (kept agnostic so unit tests that import # individual handlers don't need the starlette middleware shape). from agents._header_forwarding import HeaderForwardingHTTPMiddleware app = FastAPI(title="Claude Agent SDK (Python) Agent Server") app.add_middleware(HealthMiddleware) # Capture inbound CopilotKit ``x-*`` headers (e.g. ``x-aimock-context``) # into a per-request ContextVar so any outbound LLM/provider httpx call # made inside the request scope copies them onto its outbound request. # Paired with ``install_global_httpx_hook`` at the top of agent_server.py. app.add_middleware(HeaderForwardingHTTPMiddleware) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.post("/") async def run_agent_endpoint(request: Request) -> StreamingResponse: body = await request.json() input_data = RunAgentInput(**body) async def event_stream() -> AsyncIterator[str]: async for chunk in run_agent(input_data): yield chunk return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) return appWhat is this?#
By default, agent state only updates between backend checkpoints, so a long-running tool call (writing a full document, drafting an email) appears to the UI as one big burst at the end. For agent-native apps, that feels broken: users expect to watch the output materialise.
State streaming forwards the value of a specific tool argument
straight into an agent state key as the argument is being generated.
The UI, subscribed via useAgent, re-renders every token.
When should I use this?#
Use state streaming whenever a tool's output is long-form text or a growing structured value and you want the user to see it assemble in real time. Common shapes:
- A collaborative writing agent that emits a document
- A research agent that accumulates a list of findings
- A planning agent that builds up a step-by-step plan
Without streaming, the user stares at a spinner. With streaming, they see the answer grow token-by-token.
The backend: one streaming state mapping#
Stream partial state updates while Claude responds
For streaming state, parse the agent's structured deltas as they arrive and emit CopilotKit state updates before the final message is complete. This branch runs inside the streamed tool-argument handler.
if current_tool_name == "write_document":
streamed_document = _partial_json_string_property(
current_tool_args,
"document",
)
The backend pattern is always the same: map one streaming tool argument
to one shared-state key. Middleware-backed frameworks usually expose
this as a declarative mapping — for example, LangGraph Python's
StateStreamingMiddleware with StateItem(...) entries, or
copilotkitCustomizeConfig with an emitIntermediateState mapping for
LangGraph TypeScript graphs. Direct SDK adapters do the same work in
their streaming loop by parsing partial tool arguments and emitting
STATE_SNAPSHOT whenever the mapped value changes. When the LLM streams
that argument, CopilotKit writes every partial value into shared state
before the tool even finishes executing.
import jsonfrom collections.abc import AsyncIteratorfrom typing import Anyfrom ag_ui.core import EventType, StateSnapshotEventfrom ag_ui.encoder import EventEncoderfrom pydantic import BaseModelclass AgentState(BaseModel): document: str = ""WRITE_DOCUMENT_TOOL_SCHEMA: dict[str, Any] = { "name": "write_document", "description": "Write a document into shared agent state.", "input_schema": { "type": "object", "properties": { "document": { "type": "string", "description": "The full document text to render in shared state.", }, }, "required": ["document"], },}def _partial_json_string_property(source: str, key: str) -> str | None: """Return the current value of a streamed JSON string property.""" marker = json.dumps(key) key_pos = source.find(marker) if key_pos < 0: return None colon_pos = source.find(":", key_pos) if colon_pos < 0: # The colon hasn't streamed yet (e.g. source is `{"document`); bail out # rather than letting find(":") return -1 and matching the key's own # opening quote, which would report the key name as the value. return None value_start = source.find('"', colon_pos + 1) if value_start < 0: return None raw_chars: list[str] = [] escaped = False for char in source[value_start + 1 :]: if escaped: raw_chars.append("\\" + char) escaped = False elif char == "\\": escaped = True elif char == '"': break else: raw_chars.append(char) try: return json.loads(f'"{"".join(raw_chars)}"') except json.JSONDecodeError: return Noneasync def stream_document_state( anthropic_stream: AsyncIterator[Any], state: AgentState,) -> AsyncIterator[str]: encoder = EventEncoder() current_tool_name: str | None = None current_tool_args = "" last_streamed_document = state.document async for event in anthropic_stream: if ( event.type == "content_block_start" and event.content_block.type == "tool_use" ): current_tool_name = event.content_block.name current_tool_args = "" continue if ( event.type != "content_block_delta" or event.delta.type != "input_json_delta" ): continue current_tool_args += event.delta.partial_json if current_tool_name != "write_document": continue streamed_document = _partial_json_string_property( current_tool_args, "document", ) if streamed_document is None or streamed_document == last_streamed_document: continue state.document = streamed_document last_streamed_document = streamed_document yield encoder.encode( StateSnapshotEvent( type=EventType.STATE_SNAPSHOT, snapshot=state.model_dump(), ) )A few things to note:
- The state key must exist in your agent state (
documentin this demo). - The tool and argument names must match the exact LLM-facing tool call
you want to forward (
write_document.documenthere). - When the tool call completes, its final return value is written to the same key, so the streamed partial eventually becomes the authoritative final value.
The frontend: useAgent + OnStateChanged#
The UI side is identical to any other shared-state subscription:
useAgent with OnStateChanged gives you a reactive agent.state.
Add OnRunStatusChanged if you want a "LIVE" / "done" indicator.
// Subscribe to BOTH state changes and run-status changes. The former // drives the per-token document rerender; the latter toggles the // "LIVE" badge when the agent starts / stops. const { agent } = useAgent({ agentId: "shared-state-streaming", updates: [UseAgentUpdate.OnStateChanged, UseAgentUpdate.OnRunStatusChanged], });From there, agent.state.document is just a string that grows on every
token, and agent.isRunning tells you whether to show a streaming
indicator.
Related#
- Shared State (overview) — the bidirectional read + write pattern this extends.
- Agent read-only context — for the inverse, UI → agent one-way channel.