CSS Customization

Theme CopilotKit components via CSS variables and class overrides.


"""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 app

What is this?#

CopilotKit has a variety of ways to customize the colors and structure of the Copilot UI components via plain CSS. You can:

  • Override CopilotKit CSS variables to re-tint the whole UI
  • Target the built-in class names (.copilotKit...) for structural tweaks
  • Swap fonts per surface (messages, input, bubbles)
  • Replace icons and labels via component props

If you need to change behavior, not just look, see slots or fully headless UI.

Scoping the theme#

The demo keeps all of its styling in a sibling theme.css file and applies it only to the wrapper div holding <CopilotChat>. Importing the stylesheet from the page module is enough; Next.js bundles it with the route:

page.tsx
import "./theme.css";

Scoping every selector under a wrapper class keeps the overrides from leaking into the rest of the app.

CSS Variables (Easiest)#

The easiest way to change the colors used in the Copilot UI components is to override CopilotKit CSS variables. The demo sets them on the scope wrapper so they cascade into every nested chat component:

theme.css
/* HALCYON palette — a private library at golden hour. The whole theme is * one warm parchment hue, one warm ink, and a deep copper ember used * sparingly so it actually reads as a signal. */.chat-css-demo-scope {  --halcyon-paper: #f4efe6;  --halcyon-paper-soft: #ece6d9;  --halcyon-paper-elevated: #fbf8f2;  --halcyon-card: #ffffff;  --halcyon-rule: #d6cfbe;  --halcyon-rule-strong: #aea48a;  --halcyon-ink: #1a1714;  --halcyon-ink-soft: #3d362e;  --halcyon-ink-mute: #7a7468;  --halcyon-ember: #c44a1f;  --halcyon-ember-bright: #e45f2b;  --halcyon-ember-soft: #f3d7c5;  --halcyon-champagne: #98794a;  --halcyon-display:    "Instrument Serif", ui-serif, "Iowan Old Style", Georgia, serif;  --halcyon-serif:    "Fraunces", "Source Serif Pro", ui-serif, Georgia, "Times New Roman", serif;  --halcyon-sans:    "Inter Tight", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",    sans-serif;  --halcyon-mono:    "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;  --halcyon-shadow-soft:    0 1px 0 rgba(26, 23, 20, 0.04), 0 12px 32px -18px rgba(26, 23, 20, 0.18);  --halcyon-shadow-ember:    0 1px 0 rgba(196, 74, 31, 0.18), 0 14px 36px -16px rgba(196, 74, 31, 0.42);}

Once you've found the right variable, you can also apply the overrides inline via the CopilotKitCSSProperties helper:

import { CopilotKitCSSProperties } from "@copilotkit/react-ui";

<div
  style={
    {
      "--copilot-kit-primary-color": "#222222",
    } as CopilotKitCSSProperties
  }
>
  <CopilotSidebar />
</div>

Reference#

CSS VariableDescription
--copilot-kit-primary-colorMain brand/action color for buttons and interactive elements
--copilot-kit-contrast-colorColor that contrasts with primary, used for text on primary elements
--copilot-kit-background-colorMain page/container background color
--copilot-kit-secondary-colorSecondary background for cards, panels, and elevated surfaces
--copilot-kit-secondary-contrast-colorPrimary text color for main content
--copilot-kit-separator-colorBorder color for dividers and containers
--copilot-kit-muted-colorMuted color for disabled/inactive states
--copilot-kit-shadow-sm / -md / -lgElevation shadows for subtle surfaces, cards, and modals

Two token systems

The --copilot-kit-* variables above style the v1 component CSS (@copilotkit/react-ui). The newer v2 components (@copilotkit/react-core/v2) are Tailwind + shadcn-based and use a separate set of design tokens. See v2 design tokens below.

v2 Design Tokens (shadcn)#

The v2 components (@copilotkit/react-core/v2) ship a Tailwind v4 theme built on the standard shadcn/ui token set. Instead of the --copilot-kit-* variables, they read oklch color tokens that are scoped to the [data-copilotkit] root and wired into Tailwind utilities through an @theme inline block. This means you can re-skin the entire v2 UI by overriding a handful of CSS custom properties. Every component picks the change up automatically.

Override them on the [data-copilotkit] element (or any ancestor) the same way you would in a shadcn project:

globals.css
[data-copilotkit] {
  --primary: oklch(0.55 0.22 264); /* accent / action color */
  --primary-foreground: oklch(0.99 0 0); /* text on primary */
  --background: oklch(1 0 0); /* surface background */
  --foreground: oklch(0.145 0 0); /* primary text */
  --muted: oklch(0.97 0 0); /* subtle backgrounds */
  --border: oklch(0.922 0 0); /* dividers, outlines */
  --radius: 0.625rem; /* global corner radius */
}

/* Dark mode is keyed off a `.dark` ancestor */
.dark [data-copilotkit] {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --border: oklch(0.269 0 0);
}

Reference#

These are the most commonly overridden v2 tokens. Each light value has a matching dark-mode value under .dark [data-copilotkit]. The full set (popover, accent, destructive, chart, and sidebar variants) lives in @copilotkit/react-core/v2/styles.css.

TokenDescription
--background / --foregroundBase surface background and primary text color
--primary / --primary-foregroundAccent/action color and the text rendered on top of it
--secondary / --secondary-foregroundSecondary surfaces (cards, panels) and their text
--muted / --muted-foregroundSubtle backgrounds and de-emphasized text
--accent / --accent-foregroundHover/active states and their text
--border / --input / --ringDivider/outline color, input borders, focus ring
--destructive / --destructive-foregroundError/danger color and its text
--card / --popover (+ -foreground)Elevated surface backgrounds and their text
--sidebar-*The sidebar's own background/foreground/border/ring set
--radiusBase corner radius; --radius-sm/md/lg/xl derive from it

oklch values

v2 tokens use the oklch() color space, which keeps perceived lightness consistent across hues. You can still pass hsl(), rgb(), or hex; any valid CSS color works.

Custom CSS#

The CopilotKit CSS is structured to allow customization via CSS classes. You can target specific pieces of the UI from your own stylesheet:

globals.css
.copilotKitButton {
  border-radius: 0;
}

.copilotKitMessages {
  padding: 2rem;
}

.copilotKitUserMessage {
  background: #007AFF;
}

The demo's theme.css wraps every selector under .chat-css-demo-scope so the overrides don't leak out. Here's the user message bubble block from that file:

theme.css
/* User message — a "transmission" in JetBrains Mono on a paper card. The * outer wrapper is the right-aligning flex column; we leave it transparent * and style the inner bubble (which uses cpk:bg-muted, hence we also * target the substring class as a stable hook). */.chat-css-demo-scope .copilotKitMessage.copilotKitUserMessage {  background: transparent;  padding: 0;  border: none;  box-shadow: none;}.chat-css-demo-scope  .copilotKitMessage.copilotKitUserMessage  > [class*="bg-muted"] {  font-family: var(--halcyon-mono);  font-size: 0.875rem;  font-weight: 400;  color: var(--halcyon-ink);  background: var(--halcyon-paper-elevated);  border: 1px solid var(--halcyon-rule);  border-left: 2px solid var(--halcyon-ember);  border-radius: 0;  padding: 12px 16px 12px 18px;  letter-spacing: -0.005em;  line-height: 1.55;  box-shadow: 0 1px 0 rgba(26, 23, 20, 0.03);  position: relative;}/* A mono "→" marker before the user's text to read like a CLI prompt. */.chat-css-demo-scope  .copilotKitMessage.copilotKitUserMessage  > [class*="bg-muted"]::before {  content: "→";  display: inline-block;  margin-right: 10px;  color: var(--halcyon-ember);  font-weight: 500;}

Reference#

CSS ClassDescription
.copilotKitMessagesMain container for all chat messages
.copilotKitMessageBase class applied to every message bubble (user and assistant)
.copilotKitInputText input container with typing area and send button
.copilotKitUserMessageStyling for user messages
.copilotKitAssistantMessageStyling for AI responses
.copilotKitHeaderTop bar of chat window containing title and controls
.copilotKitButtonPrimary chat toggle button
.copilotKitWindowRoot container defining overall chat window dimensions
.copilotKitMarkdownStyles for rendered markdown content
.copilotKitCodeBlockCode snippet container with syntax highlighting
.copilotKitSidebarStyles for sidebar chat mode
.copilotKitPopupStyles for popup chat mode

Custom Fonts#

You can customize the fonts by updating the fontFamily property on the relevant CopilotKit classes:

globals.css
.copilotKitMessages {
  font-family: "Arial, sans-serif";
}

.copilotKitInput {
  font-family: "Arial, sans-serif";
}

Custom Icons#

Customize icons by passing the icons prop to CopilotSidebar, CopilotPopup, or CopilotChat:

<CopilotChat
  icons={{
    openIcon: <YourOpenIconComponent />,
    closeIcon: <YourCloseIconComponent />,
  }}
/>

Custom Labels#

Customize all user-facing copy via the labels prop:

<CopilotChat
  labels={{
    welcomeMessageText: "Hello! How can I help you today?",
    modalHeaderTitle: "My Copilot",
    chatInputPlaceholder: "Ask me anything!",
  }}
/>