chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,219 @@
"""
Agent Server for AG2
FastAPI server that hosts the AG2 agent backends.
The Next.js CopilotKit runtime proxies requests here via AG-UI protocol.
Most demos share a single ConversableAgent at the root path. Demos that
require dedicated state mechanics or multi-agent topologies are mounted
as their own sub-apps at distinct paths so each demo gets its own
ContextVariables-backed state slot.
"""
# ORDER-CRITICAL: load .env BEFORE any agent module imports. The agent
# modules (agents/agent.py et al.) construct module-level
# ``openai.AsyncOpenAI()`` / autogen ``LLMConfig`` clients that read
# ``OPENAI_API_KEY`` (and friends) at construction time. If we import the
# agent modules before calling ``load_dotenv()``, those module-level
# clients latch onto whatever the OS environment had at import time
# (usually nothing in a dev shell), and subsequent .env values never
# reach them. ``load_dotenv()`` is idempotent so the redundant call
# inside each agent module is harmless — but the FIRST call must happen
# here, before the agent imports below.
# CVDIAG bootstrap — MUST be the first non-stdlib import (folded in from the
# dropped L1-H slot). Importing this module configures the root logger via
# ``logging.basicConfig`` so the ``agents._header_forwarding`` (and sibling
# ``agents.*``) CVDIAG loggers actually EMIT (fixes the silent-drop bug), and
# resolves the verbosity tier + PB writer. It imports pydantic/starlette only
# and has no dependency on ``.env``, so it is safe to run before ``load_dotenv``.
import _shared.cvdiag_bootstrap # noqa: F401,E402 (first non-stdlib import — bootstrap side effects)
from dotenv import load_dotenv
load_dotenv()
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
# imports. The autogen / openai SDK construct their httpx client lazily
# per-call, but other integrations construct at module-import time;
# keeping the patch at the top of agent_server.py is the consistent
# placement across all Python showcase integrations and is harmless here.
from agents._cvdiag_backend import CvdiagBackendMiddleware
from agents._header_forwarding import (
HeaderForwardingHTTPMiddleware,
install_executor_contextvar_propagation,
install_global_httpx_hook,
)
from agents._request_context import RequestUserMessageMiddleware
install_global_httpx_hook()
# AG2-specific: autogen's ConversableAgent.a_generate_oai_reply dispatches
# the underlying sync LLM call onto the default ThreadPoolExecutor via
# loop.run_in_executor(...), which does NOT propagate ContextVars to the
# worker thread. Without this, the forwarded-header ContextVar set on the
# inbound request task is empty by the time the outbound httpx hook fires,
# and aimock can't match the right fixture for the request.
install_executor_contextvar_propagation()
from agents.agent import stream as default_stream
from agents.a2ui_dynamic import a2ui_dynamic_app
from agents.a2ui_fixed import a2ui_fixed_app
from agents.agent_config_agent import agent_config_app
from agents.beautiful_chat import beautiful_chat_app
from agents.byoc_hashbrown_agent import byoc_hashbrown_app
from agents.byoc_json_render_agent import byoc_json_render_app
from agents.gen_ui_agent import gen_ui_agent_app
from agents.headless_complete import headless_complete_app
from agents.mcp_apps_agent import mcp_apps_app
from agents.multimodal_agent import multimodal_app
from agents.open_gen_ui_advanced_agent import open_gen_ui_advanced_app
from agents.open_gen_ui_agent import open_gen_ui_app
from agents.shared_state_read_write import (
shared_state_read_write_app,
)
from agents.subagents import subagents_app
from agents.interrupt_agent import interrupt_app
from agents.reasoning_agent import reasoning_app
from agents.tool_rendering_reasoning_chain import (
tool_rendering_reasoning_chain_app,
)
app = FastAPI(title="AG2 Agent Server")
# Serve /health via middleware so it short-circuits BEFORE route resolution.
# A plain `@app.get("/health")` decorator is shadowed by the subsequent
# `app.mount("/", ...)` call: Starlette's Mount at "/" matches every path
# (including /health) and the decorated route never fires. Middleware runs
# above the routing layer, so the health endpoint stays reachable regardless
# of what the framework-specific AG-UI adapter mounts at root.
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)
# ORDER-CRITICAL: Starlette's ``add_middleware`` is LIFO — the LAST call
# becomes the OUTERMOST layer in the request pipeline. This ordering
# matters because ``BaseHTTPMiddleware`` (HealthMiddleware,
# HeaderForwardingHTTPMiddleware) internally uses anyio TaskGroups that
# can sever ``contextvars.ContextVar`` propagation from outer layers to
# the inner ASGI app. The raw-ASGI ``RequestUserMessageMiddleware`` sets
# a ContextVar that downstream tool handlers must observe, so it MUST
# sit OUTSIDE the BaseHTTPMiddleware layers — i.e. be added LAST so it
# wraps them. CORSMiddleware (also raw ASGI) is added last of all so it
# remains the absolute outermost layer (handles preflight + headers
# before anything else runs).
#
# Resulting outer→inner execution order:
# CORS → RequestUserMessage → HeaderForwarding → Health → routes/mounts
# Innermost: serve /health via middleware so it short-circuits BEFORE
# route resolution. (Already declared above as HealthMiddleware.)
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. The matching
# ``install_httpx_hook(...)`` call lives next to each LLM client
# construction site (see ``agents/agent.py``).
app.add_middleware(HeaderForwardingHTTPMiddleware)
# CVDIAG backend emitter (spec §3 Layer 2) — emits the HTTP-observable backend
# boundaries (request.ingress, sse.first_byte, sse.event, sse.aborted,
# response.complete, error.caught) as structured CVDIAG envelopes. Added here so
# it wraps the Health + HeaderForwarding BaseHTTPMiddleware layers but stays
# INSIDE the outer raw-ASGI RequestUserMessage + CORS layers (CORS remains the
# absolute outermost so preflight is handled first). Gated behind
# ``CVDIAG_BACKEND_EMITTER`` (default OFF, canary-safe) — the middleware
# fast-paths to a bare pass-through when the flag is unset.
app.add_middleware(CvdiagBackendMiddleware)
# R2-A3: Capture the latest user message from each inbound RunAgentInput POST
# into a per-request ContextVar so tool handlers (e.g. generate_a2ui) can read
# the per-request prompt without consulting autogen's shared, race-prone
# ``ConversableAgent.chat_messages`` state. See agents/_request_context.py.
# Added AFTER the BaseHTTPMiddlewares above so it wraps them (raw ASGI on
# the outside preserves ContextVar propagation across the anyio
# TaskGroups they spawn internally).
app.add_middleware(RequestUserMessageMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Mount per-demo sub-apps FIRST. Starlette's router resolves mounts in
# registration order; the catch-all `/` mount below shadows everything
# under it, so the named mounts must come first.
app.mount("/shared-state-read-write", shared_state_read_write_app)
app.mount("/subagents", subagents_app)
app.mount("/headless-complete", headless_complete_app)
app.mount("/gen-ui-agent", gen_ui_agent_app)
app.mount("/declarative-gen-ui", a2ui_dynamic_app)
app.mount("/a2ui-fixed-schema", a2ui_fixed_app)
app.mount("/beautiful-chat", beautiful_chat_app)
app.mount("/mcp-apps", mcp_apps_app)
# IMPORTANT: mount /open-gen-ui-advanced BEFORE /open-gen-ui — Starlette
# resolves mounts via prefix matching in registration order, so the shorter
# prefix "/open-gen-ui" would shadow "/open-gen-ui-advanced" if it came first.
app.mount("/open-gen-ui-advanced", open_gen_ui_advanced_app)
app.mount("/open-gen-ui", open_gen_ui_app)
app.mount(
"/tool-rendering-reasoning-chain",
tool_rendering_reasoning_chain_app,
)
# Reasoning-aware route. AG2's stock AGUIStream emits no REASONING_MESSAGE_*
# events (and autogen drops the model's reasoning_content channel), so the
# reasoning-custom / reasoning-default cells use this custom sub-app instead.
# Mirrors agno's /reasoning/agui mount.
app.mount("/reasoning", reasoning_app)
app.mount("/agent-config", agent_config_app)
app.mount("/multimodal", multimodal_app)
app.mount("/byoc-hashbrown", byoc_hashbrown_app)
app.mount("/byoc-json-render", byoc_json_render_app)
# Interrupt-adapted scheduling agent. Shared by gen-ui-interrupt and
# interrupt-headless demos — backend has tools=[], the frontend provides
# `schedule_meeting` via `useFrontendTool` with an async Promise handler.
app.mount("/interrupt-adapted", interrupt_app)
# Mount the default AG2 AG-UI endpoint at the root.
# `app.mount("/", ...)` is a catch-all Mount that shadows any later route
# decorators, which is why /health is served by HealthMiddleware above
# rather than a `@app.get("/health")` handler registered here.
app.mount("/", default_stream.build_asgi())
def main():
"""Run the uvicorn server.
``reload=True`` is gated behind ``DEV_RELOAD=1`` so production
containers (which set neither var) get a single non-reloading
process. The reloader spawns a watcher process and re-imports the
app on every file change, which is appropriate for local dev but
burns memory + risks half-imported state in prod.
"""
port = int(os.getenv("PORT", "8000"))
dev_reload = os.getenv("DEV_RELOAD", "0") == "1"
uvicorn.run(
"agent_server:app",
host="0.0.0.0",
port=port,
reload=dev_reload,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,812 @@
"""_cvdiag_backend.py — backend-layer CVDIAG boundary instrumentation.
This module wires the spec §3 / §5 **11 backend boundaries** into a Python
showcase integration, emitting schema-v1 CVDIAG envelopes through the shared
``_shared.cvdiag_bootstrap.emit_cvdiag`` sink. It is the per-integration
companion to the header-forwarding shim (``_header_forwarding.py``): that file
forwards correlation headers onto outbound LLM calls and logs lightweight
``CVDIAG component=backend-<fw> boundary=...`` breadcrumbs; THIS file emits the
full structured ``CVDIAG {<json>}`` envelopes the harness/classifier consume.
The 11 backend boundaries (spec §5 / §6 tier matrix):
1. ``backend.request.ingress`` — HTTP request received (default)
2. ``backend.agent.enter`` — agent loop entered (default)
3. ``backend.llm.call.start`` — outbound LLM call dispatched (verbose)
4. ``backend.llm.call.heartbeat`` — fires ~10s while an LLM call is
outstanding (verbose)
5. ``backend.llm.call.response`` — LLM response received (verbose)
6. ``backend.sse.first_byte`` — first SSE byte written (verbose)
7. ``backend.sse.event`` — every SSE event written (debug)
8. ``backend.sse.aborted`` — stream terminated abnormally (default)
9. ``backend.agent.exit`` — agent loop exited (default)
10. ``backend.response.complete`` — HTTP response stream closed (default)
11. ``backend.error.caught`` — exception caught in the agent loop
(default)
Guarding
--------
ALL emission is gated behind the ``CVDIAG_BACKEND_EMITTER`` env flag, default
OFF. With the flag off this module is byte-for-byte inert — no envelope is
built, no stdout line is written, the middleware passes the request straight
through. This is the canary-safe default: the flag is flipped ON only after a
deploy is confirmed healthy.
Tier gating
-----------
Each boundary carries a tier per the §6 matrix. ``_shared.cvdiag_bootstrap``
resolves the active tier (default | verbose | debug) once at import; this
module suppresses a boundary whose tier exceeds the active tier so the
default-tier production emit stays within the §7 event-count budget.
Pure instrumentation
--------------------
Nothing here may throw into the request path. ``emit_cvdiag`` already swallows
its own errors; the helpers below additionally guard envelope construction so a
malformed metadata bag degrades to a dropped emit, never a 500.
Plan unit: L1-C.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import secrets
import time
import uuid
from typing import Any, Dict, Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
logger = logging.getLogger(__name__)
# Framework tag — mirrors ``_header_forwarding._CVDIAG_FRAMEWORK`` so the
# structured envelopes and the breadcrumb log lines agree on the integration
# identity. (L1-D: change this single constant when copying to a sibling.)
_CVDIAG_FRAMEWORK = "ag2"
# ── Env gate ─────────────────────────────────────────────────────────────────
_BACKEND_EMITTER_ENV = "CVDIAG_BACKEND_EMITTER"
def cvdiag_backend_enabled() -> bool:
"""True iff the backend emitter is explicitly enabled (default OFF).
Read live (not cached) so a test can toggle the env var per-case via
``monkeypatch.setenv``; the cost is one ``os.environ`` lookup per emit,
which is negligible against the JSON serialization that follows.
"""
return os.environ.get(_BACKEND_EMITTER_ENV) == "1"
# ── Tier ordering (spec §6) ────────────────────────────────────────────────
_TIER_RANK = {"default": 0, "verbose": 1, "debug": 2}
# Per-boundary minimum tier required to emit (spec §6 matrix, backend rows).
_BOUNDARY_TIER: Dict[str, str] = {
"backend.request.ingress": "verbose",
"backend.agent.enter": "default",
"backend.llm.call.start": "verbose",
"backend.llm.call.heartbeat": "verbose",
"backend.llm.call.response": "verbose",
"backend.sse.first_byte": "verbose",
"backend.sse.event": "debug",
"backend.sse.aborted": "default",
"backend.agent.exit": "default",
"backend.response.complete": "default",
"backend.error.caught": "default",
}
def _active_tier() -> str:
"""Resolve the verbosity tier from a LIVE env read.
``cvdiag_backend_enabled()`` reads ``CVDIAG_BACKEND_EMITTER`` live, so the
tier MUST be read from the same live source — otherwise flipping
``CVDIAG_VERBOSE`` / ``CVDIAG_DEBUG`` AFTER import arms the emitter but the
tier stays frozen at the import-time ``setup()`` value, silently no-op'ing
every verbose/debug-gated boundary. We reuse the bootstrap's
``_resolve_tier`` so the §6 fail-closed DEBUG guard still applies (a
production / unresolved DEBUG request raises → degrade to the frozen tier).
"""
try:
return _resolve_tier(dict(os.environ))
except RuntimeError:
# Fail-closed DEBUG refusal: fall back to the import-time resolved tier
# (never silently escalate to debug in production).
return current_tier()
def _tier_permits(boundary: str) -> bool:
"""True iff the active tier is at-or-above the boundary's minimum tier."""
need = _TIER_RANK.get(_BOUNDARY_TIER.get(boundary, "default"), 0)
have = _TIER_RANK.get(_active_tier(), 0)
return have >= need
# ── Edge headers (spec §5 — 9-key allow-list + 12-name deny-list) ───────────
# The closed 9-key edge-header allow-list. Always-present in the envelope;
# absent header → ``None``.
_EDGE_ALLOW = (
"cf-ray",
"cf-mitigated",
"cf-cache-status",
"x-railway-edge",
"x-railway-request-id",
"x-hikari-trace",
"retry-after",
"via",
"server",
)
# Exact-match deny-list (spec §5). REJECTED even if accidentally present in the
# allow-list — these carry client IP / geo PII and must never round-trip.
_EDGE_DENY = frozenset(
{
"cf-ipcountry",
"cf-connecting-ip",
"cf-ipcity",
"cf-iplatitude",
"cf-iplongitude",
"cf-iptimezone",
"cf-visitor",
"cf-worker",
"true-client-ip",
"x-forwarded-for",
"x-real-ip",
"forwarded",
}
)
def extract_edge_headers(headers: Any) -> Dict[str, Optional[str]]:
"""Build the closed 9-key ``edge_headers`` bag from a headers mapping.
All nine keys are ALWAYS present; an absent (or deny-listed) header maps to
``None``. ``headers`` is any case-insensitive mapping exposing ``.get`` /
iteration of ``(name, value)`` pairs (Starlette ``Headers``, httpx, dict).
"""
bag: Dict[str, Optional[str]] = {k: None for k in _EDGE_ALLOW}
if headers is None:
return bag
try:
getter = headers.get
except AttributeError:
return bag
for key in _EDGE_ALLOW:
if key in _EDGE_DENY: # belt-and-braces: never emit a deny-listed key
continue
val = getter(key)
if val is not None:
bag[key] = str(val)
return bag
# ── PII scrub (spec §6) ──────────────────────────────────────────────────────
# Bearer tokens, OpenAI/Stripe-style secret keys, publishable keys, and URL
# userinfo. Applied to any captured free-text metadata value
# (``message_scrubbed``, stack frames) before it is emitted. The ``sk-``/``pk-``
# key bodies allow hyphens/underscores so test-style keys such as the spec
# regression fixture ``sk-test-12345`` are redacted alongside real production
# keys (``sk-<48+ base62>``).
#
# Parity with the canonical TS scrubber (``harness/src/cvdiag/scrub.ts``):
# * Bearer — grabs the WHOLE token (``\S+``) to match TS ``Bearer\s+\S+``;
# the legacy ``[A-Za-z0-9._\-]+`` stopped at ``/``/``+``/``=`` and left an
# un-redacted JWT tail (e.g. ``Bearer a.b.c/sig+more=`` → ``…/sig+more=``).
# * URL userinfo — redacts BOTH ``scheme://user:pw@host`` AND colon-less
# ``scheme://token@host`` (TS ``([scheme]://)[^/\s?#]*@``); the legacy
# ``[^/\s:@]+:[^/\s@]+@`` required a mandatory ``:`` so a bare-token
# authority such as ``https://ghp_xxx@host`` LEAKED. The userinfo class
# excludes ``?``/``#`` so the match never crosses into the query/fragment.
_SCRUB_PATTERNS = (
re.compile(r"Bearer\s+\S+", re.IGNORECASE),
re.compile(r"\bsk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
re.compile(r"\bpk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
re.compile(r"(?P<scheme>[a-z][a-z0-9+.\-]*://)[^/\s?#]*@", re.IGNORECASE),
)
# Per-event field byte caps (spec §5). message_scrubbed ≤512B.
_MESSAGE_CAP = 512
# Hard input-size guard (mirrors TS ``SCRUB_MAX_SCAN_LEN``): no regex ever runs
# on a string longer than this. A longer value has only its bounded prefix
# scanned and a self-describing ``…[unscanned:<N>]`` marker records the dropped
# tail length, so an adversarial multi-KB string can never make the regex
# engine scan unbounded input. 2 KB covers any legitimate metadata value with
# headroom. Set below the byte cap so the marker survives the §5 byte clamp.
_SCRUB_MAX_SCAN_LEN = 400
def _run_scrub_regexes(s: str) -> str:
"""Apply the secret regexes in sequence (TS ``runScrubRegexes`` parity)."""
for pat in _SCRUB_PATTERNS:
if pat.groupindex.get("scheme"):
s = pat.sub(r"\g<scheme>[REDACTED]@", s)
else:
s = pat.sub("[REDACTED]", s)
return s
def scrub(text: Any) -> str:
"""Redact secrets from a free-text value and cap it at 512 bytes.
Returns ``"[REDACTED]"`` substitutions for any matched secret pattern so a
synthetic ``sk-test-12345`` in an exception message can never reach the
emitted envelope. A value longer than ``_SCRUB_MAX_SCAN_LEN`` has only its
bounded prefix scanned, with an ``…[unscanned:<N>]`` marker (TS parity).
"""
if text is None:
return ""
s = str(text)
if len(s) > _SCRUB_MAX_SCAN_LEN:
dropped_tail = len(s) - _SCRUB_MAX_SCAN_LEN
s = f"{_run_scrub_regexes(s[:_SCRUB_MAX_SCAN_LEN])}…[unscanned:{dropped_tail}]"
else:
s = _run_scrub_regexes(s)
encoded = s.encode("utf-8")
if len(encoded) > _MESSAGE_CAP:
s = encoded[:_MESSAGE_CAP].decode("utf-8", errors="ignore")
return s
# ── Envelope construction ──────────────────────────────────────────────────
_TEST_ID_HEADER = "x-test-id"
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
# UUIDv7 variant/version nibbles (RFC 9562) the schema regex requires.
_SLUG_FALLBACK = "unknown"
_DEMO_FALLBACK = "default"
def _uuid7() -> str:
"""Generate a lowercase-hyphenated UUIDv7 (RFC 9562) string.
48-bit Unix-ms timestamp, version nibble 7, variant 10 — matches the
schema ``TEST_ID_PATTERN``. Used as the fallback ``test_id`` when no
inbound ``x-test-id`` correlation header is present.
"""
unix_ms = int(time.time() * 1000) & ((1 << 48) - 1)
rand_a = secrets.randbits(12)
rand_b = secrets.randbits(62)
msb = (unix_ms << 16) | (0x7 << 12) | rand_a
lsb = (0b10 << 62) | rand_b
return str(uuid.UUID(int=(msb << 64) | lsb))
_UUID7_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
def normalize_test_id(raw: Optional[str]) -> str:
"""Return a schema-valid lowercased UUIDv7, minting one if ``raw`` is
absent or not a well-formed UUIDv7."""
if raw:
candidate = raw.strip().lower()
if _UUID7_RE.match(candidate):
return candidate
return _uuid7()
def _span_id() -> str:
"""16-hex span id, unique per emit (schema ``SPAN_ID_PATTERN``)."""
return secrets.token_hex(8)
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
def _normalize_slug(raw: Optional[str]) -> str:
"""Coerce the inbound ``x-aimock-context`` slug into the closed slug shape
(``^[a-z][a-z0-9-]{0,63}$``), falling back to ``unknown`` when unusable."""
if raw:
candidate = raw.strip().lower()
if _SLUG_RE.match(candidate):
return candidate
return _SLUG_FALLBACK
def build_envelope(
*,
boundary: str,
outcome: str,
test_id: str,
slug: str,
demo: str,
metadata: Optional[Dict[str, Any]] = None,
edge_headers: Optional[Dict[str, Optional[str]]] = None,
duration_ms: Optional[int] = None,
parent_span_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Assemble a schema-v1 backend envelope (``layer=backend``).
All envelope-required fields are populated; ``edge_headers`` defaults to the
closed 9-key all-null bag when not supplied. ``metadata`` is passed through
verbatim — unknown keys are stamped ``_metadata_dropped`` by the schema
validator inside ``emit_cvdiag``.
"""
return {
"schema_version": 1,
"test_id": test_id,
"trace_id": test_id,
"span_id": _span_id(),
"parent_span_id": parent_span_id,
"layer": "backend",
"boundary": boundary,
"slug": slug,
"demo": demo,
"ts": _now_iso(),
"mono_ns": time.monotonic_ns(),
"duration_ms": duration_ms,
"outcome": outcome,
"edge_headers": edge_headers or {k: None for k in _EDGE_ALLOW},
"metadata": metadata or {},
}
def _now_iso() -> str:
"""ISO-8601 millisecond-precision timestamp with a ``Z`` suffix."""
# ``time.gmtime`` + manual ms keeps this dependency-free and 3.9-safe.
now = time.time()
secs = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now))
ms = int((now - int(now)) * 1000)
return f"{secs}.{ms:03d}Z"
def emit_backend_boundary(
boundary: str,
*,
outcome: str = "info",
test_id: str,
slug: str,
demo: str,
metadata: Optional[Dict[str, Any]] = None,
edge_headers: Optional[Dict[str, Optional[str]]] = None,
duration_ms: Optional[int] = None,
parent_span_id: Optional[str] = None,
) -> None:
"""Emit one backend boundary envelope, honoring the env gate + tier matrix.
No-op when the emitter is disabled or the active tier does not permit this
boundary. Never raises into the caller.
"""
if not cvdiag_backend_enabled():
return
if not _tier_permits(boundary):
return
try:
envelope = build_envelope(
boundary=boundary,
outcome=outcome,
test_id=test_id,
slug=slug,
demo=demo,
metadata=metadata,
edge_headers=edge_headers,
duration_ms=duration_ms,
parent_span_id=parent_span_id,
)
emit_cvdiag(envelope)
except Exception as err: # noqa: BLE001 - instrumentation must not throw
logger.warning("CVDIAG backend emit-failed boundary=%s error=%s", boundary, err)
# ── Per-request correlation context ─────────────────────────────────────────
class _RequestCtx:
"""Holds the per-request correlation identity + timing the boundaries share.
Carried on ``request.state`` so the middleware, the LLM hook, and the agent
hooks all stamp the same ``test_id`` / ``slug`` / ``demo`` onto their
envelopes.
"""
__slots__ = (
"test_id",
"slug",
"demo",
"ingress_mono_ns",
"sse_seq",
"first_byte_emitted",
"bytes_streamed",
)
def __init__(self, *, test_id: str, slug: str, demo: str) -> None:
self.test_id = test_id
self.slug = slug
self.demo = demo
self.ingress_mono_ns = time.monotonic_ns()
self.sse_seq = 0
self.first_byte_emitted = False
self.bytes_streamed = 0
def _demo_from_path(path: str) -> str:
"""Derive the ``demo`` label from the mounted sub-app path.
Each demo is mounted at ``/<demo>`` (e.g. ``/voice``, ``/byoc-hashbrown``);
the root agent serves the default demo. Strip the leading slash and any
trailing AG-UI segment so ``/byoc-hashbrown/`` → ``byoc-hashbrown`` and
``/`` → ``default``.
"""
trimmed = path.strip("/")
if not trimmed:
return _DEMO_FALLBACK
return trimmed.split("/", 1)[0] or _DEMO_FALLBACK
# ── HTTP middleware: ingress / first_byte / sse.event / sse.aborted /
# response.complete / error.caught ─────────────────────────────────────────
class CvdiagBackendMiddleware(BaseHTTPMiddleware):
"""Starlette middleware emitting the HTTP-observable backend boundaries.
Wires six of the eleven boundaries around the request lifecycle:
* ``backend.request.ingress`` on entry
* ``backend.sse.first_byte`` on the first streamed chunk
* ``backend.sse.event`` per streamed chunk (debug tier)
* ``backend.sse.aborted`` on premature stream termination
* ``backend.response.complete`` on clean stream close
* ``backend.error.caught`` on any exception escaping the inner app
The agent/LLM boundaries (``agent.enter``, ``llm.call.*``, ``agent.exit``)
are emitted by the agent hooks / LLM httpx hook installed separately, all
keyed on the same ``test_id`` this middleware stamps onto ``request.state``.
Inert when ``CVDIAG_BACKEND_EMITTER`` is off: the dispatch fast-paths to a
bare ``call_next`` with no envelope construction and no response wrapping.
"""
async def dispatch(self, request: Request, call_next) -> Response:
if not cvdiag_backend_enabled():
return await call_next(request)
headers = request.headers
ctx = _RequestCtx(
test_id=normalize_test_id(headers.get(_TEST_ID_HEADER)),
slug=_normalize_slug(headers.get(_AIMOCK_CONTEXT_HEADER)),
demo=_demo_from_path(request.url.path),
)
request.state.cvdiag = ctx
emit_backend_boundary(
"backend.request.ingress",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
edge_headers=extract_edge_headers(headers),
metadata={
"method": request.method,
"path": request.url.path,
"content_length": _int_or_none(headers.get("content-length")),
},
)
try:
response = await call_next(request)
except Exception as exc: # noqa: BLE001 - observe then re-raise
emit_backend_boundary(
"backend.error.caught",
outcome="err",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
metadata={
"exception_type": type(exc).__name__,
"message_scrubbed": scrub(str(exc)),
"stack_brief": [],
"truncated": False,
},
)
raise
return self._wrap_response(request, response, ctx)
def _wrap_response(
self, request: Request, response: Response, ctx: "_RequestCtx"
) -> Response:
"""Wrap a streaming response so SSE boundaries fire as chunks flow.
Non-streaming responses are returned unwrapped after emitting
``backend.response.complete`` directly.
NOTE: ``BaseHTTPMiddleware`` re-wraps the inner ``StreamingResponse`` as
a private ``_StreamingResponse`` before it reaches us, so an
``isinstance(response, StreamingResponse)`` check is always False here.
Detect streaming by the presence of a ``body_iterator`` (which both the
public and the private response carry) instead.
"""
if not hasattr(response, "body_iterator"):
emit_backend_boundary(
"backend.response.complete",
outcome="ok",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
edge_headers=extract_edge_headers(response.headers),
metadata={
"http_status": response.status_code,
"content_length": _int_or_none(
response.headers.get("content-length")
),
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
"sse_event_count": ctx.sse_seq,
},
)
return response
inner = response.body_iterator
edge = extract_edge_headers(response.headers)
status = response.status_code
async def _instrumented():
# ``completed`` distinguishes a clean stream exhaustion (→
# response.complete) from an early termination (→ sse.aborted).
#
# IMPORTANT (Starlette ``BaseHTTPMiddleware`` quirk): when the INNER
# endpoint generator raises mid-stream, Starlette swallows the error
# internally and our ``async for`` simply ends — we never see an
# exception there. The abort surface we CAN observe is the consumer
# tearing the stream down early (client disconnect), which closes
# this generator and raises ``GeneratorExit`` / ``CancelledError``
# into it. We therefore catch ``BaseException`` (not just
# ``Exception``) so a disconnect-driven abort is captured, and emit
# ``backend.response.complete`` only on a clean exhaustion.
completed = False
terminated_kind = "rst"
try:
async for chunk in inner:
ctx.bytes_streamed += len(chunk) if chunk else 0
if not ctx.first_byte_emitted:
ctx.first_byte_emitted = True
emit_backend_boundary(
"backend.sse.first_byte",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
edge_headers=edge,
metadata={
"delta_ms_from_ingress": _elapsed_ms(
ctx.ingress_mono_ns
)
},
)
emit_backend_boundary(
"backend.sse.event",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
metadata={
"event_type": "chunk",
"payload_size_bytes": len(chunk) if chunk else 0,
"sequence_num": ctx.sse_seq,
},
)
ctx.sse_seq += 1
yield chunk
completed = True
except BaseException as exc: # noqa: BLE001 - observe abort then re-raise
# GeneratorExit (disconnect) and CancelledError carry no
# message; an in-iterator error would. Pick a termination_kind.
terminated_kind = (
"rst"
if isinstance(exc, (GeneratorExit,))
else (
"timeout"
if isinstance(exc, asyncio.CancelledError)
else "chunk_error"
)
)
raise
finally:
if completed:
emit_backend_boundary(
"backend.response.complete",
outcome="ok",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
edge_headers=edge,
metadata={
"http_status": status,
"content_length": ctx.bytes_streamed,
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
"sse_event_count": ctx.sse_seq,
},
)
else:
emit_backend_boundary(
"backend.sse.aborted",
outcome="err",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
edge_headers=edge,
metadata={
"termination_kind": terminated_kind,
"bytes_before_abort": ctx.bytes_streamed,
},
)
response.body_iterator = _instrumented()
return response
def _int_or_none(raw: Any) -> Optional[int]:
"""Parse an int header value, returning ``None`` on absence / malformed."""
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _elapsed_ms(start_mono_ns: int) -> int:
"""Whole milliseconds elapsed since a ``time.monotonic_ns`` start mark."""
return max(0, (time.monotonic_ns() - start_mono_ns) // 1_000_000)
# ── Agent + LLM boundaries ──────────────────────────────────────────────────
# The LLM-call boundaries (start / heartbeat / response) and the agent
# enter/exit boundaries are emitted via the explicit helpers below. They are
# called from the agent factory's hook points (strands ``HookProvider``) and
# from the outbound httpx event hook, all keyed on the request ``ctx``.
def emit_agent_enter(ctx: "_RequestCtx", *, agent_name: str, model_id: str) -> None:
"""Emit ``backend.agent.enter`` (default tier)."""
emit_backend_boundary(
"backend.agent.enter",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
metadata={"agent_name": agent_name, "model_id": model_id},
)
def emit_agent_exit(
ctx: "_RequestCtx", *, terminal_outcome: str, total_duration_ms: int
) -> None:
"""Emit ``backend.agent.exit`` (default tier)."""
emit_backend_boundary(
"backend.agent.exit",
outcome="ok" if terminal_outcome == "ok" else "err",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
duration_ms=total_duration_ms,
metadata={
"terminal_outcome": terminal_outcome,
"total_duration_ms": total_duration_ms,
},
)
class LlmCallScope:
"""Async context manager spanning one outbound LLM call.
On ``__aenter__`` emits ``backend.llm.call.start`` and launches a heartbeat
task that emits ``backend.llm.call.heartbeat`` every ``interval_s`` (≈10s)
while the call is outstanding (verbose tier). On ``__aexit__`` emits
``backend.llm.call.response`` with the measured latency.
All emission is gated/tiered through ``emit_backend_boundary``, so with the
emitter off or at default tier this scope is effectively free (the
heartbeat task still ticks but every emit is suppressed; callers that want
zero task overhead can skip the scope when ``cvdiag_backend_enabled()`` is
false).
"""
def __init__(
self,
ctx: "_RequestCtx",
*,
provider: str,
model: str,
prompt_token_count_estimate: int = 0,
interval_s: float = 10.0,
) -> None:
self._ctx = ctx
self._provider = provider
self._model = model
self._prompt_tokens = prompt_token_count_estimate
self._interval_s = interval_s
self._start_mono_ns = 0
self._hb_task: Optional[asyncio.Task] = None
async def __aenter__(self) -> "LlmCallScope":
self._start_mono_ns = time.monotonic_ns()
emit_backend_boundary(
"backend.llm.call.start",
outcome="info",
test_id=self._ctx.test_id,
slug=self._ctx.slug,
demo=self._ctx.demo,
metadata={
"provider": self._provider,
"model": self._model,
"prompt_token_count_estimate": self._prompt_tokens,
},
)
self._hb_task = asyncio.ensure_future(self._heartbeat())
return self
async def _heartbeat(self) -> None:
try:
while True:
await asyncio.sleep(self._interval_s)
emit_backend_boundary(
"backend.llm.call.heartbeat",
outcome="info",
test_id=self._ctx.test_id,
slug=self._ctx.slug,
demo=self._ctx.demo,
metadata={
"elapsed_ms_since_start": _elapsed_ms(self._start_mono_ns)
},
)
except asyncio.CancelledError: # normal shutdown on call completion
return
async def __aexit__(self, exc_type, exc, tb) -> bool:
if self._hb_task is not None:
hb_task = self._hb_task
self._hb_task = None
hb_task.cancel()
try:
await hb_task
except asyncio.CancelledError:
# Cooperative cancellation (was ``except (CancelledError,
# Exception)``, which swallowed the CALLER's cancel and broke
# cooperative cancellation). Suppress ONLY the heartbeat task's
# OWN cancellation — the one we just requested. If THIS task is
# being cancelled by the caller (a pending cancellation request,
# ``current_task().cancelling() > 0``), the CancelledError is the
# caller's and MUST propagate. ``Task.cancelling()`` is 3.11+
# (production runs 3.12); on older runtimes the attribute is
# absent and we degrade to suppressing (the legacy behavior).
current = asyncio.current_task()
cancelling = getattr(current, "cancelling", None)
if current is not None and cancelling is not None and cancelling() > 0:
raise
except Exception: # noqa: BLE001 - heartbeat body must never throw out
pass
emit_backend_boundary(
"backend.llm.call.response",
outcome="err" if exc_type is not None else "ok",
test_id=self._ctx.test_id,
slug=self._ctx.slug,
demo=self._ctx.demo,
duration_ms=_elapsed_ms(self._start_mono_ns),
metadata={
"provider": self._provider,
"model": self._model,
"response_token_count": None,
"latency_ms": _elapsed_ms(self._start_mono_ns),
"error_class": type(exc).__name__ if exc is not None else None,
},
)
return False # never suppress the underlying exception
@@ -0,0 +1,466 @@
"""Standalone header-forwarding shim for showcase integrations.
Forward CopilotKit request-context headers (e.g. ``x-aimock-context``)
onto outbound LLM/provider HTTP calls so the locally-served aimock test
server can match the right fixture for each in-flight showcase request.
This module is a SELF-CONTAINED port of the langgraph-python reference
shim at ``copilotkit/header_propagation.py`` plus a small Starlette HTTP
middleware that extracts inbound ``x-*`` headers at request scope.
It is intentionally duplicated into every Python showcase integration
that does NOT already depend on the ``copilotkit`` SDK so each backend
has a single self-contained file it can import without adding a heavy
``copilotkit`` (langchain-pulling) dependency.
What this module does
---------------------
Three things, kept deliberately small:
1. ``HeaderForwardingHTTPMiddleware`` — a Starlette/FastAPI HTTP
middleware that, on every inbound request, extracts ``x-*`` prefixed
headers and stashes them on a per-request ``contextvars.ContextVar``.
2. ``install_httpx_hook(client)`` — attaches an httpx request event hook
to the given LLM client's underlying httpx client (walking the
``._client`` chain that modern provider SDKs wrap their httpx client
behind). The hook copies the recorded headers onto outbound requests.
3. ``set_forwarded_headers`` / ``get_forwarded_headers`` — direct
ContextVar accessors for integrations that need to populate the
header set from a non-HTTP source (e.g. LangGraph's RunnableConfig
``configurable`` channel).
Scope and limits
----------------
* Only ``x-*`` prefixed headers are forwarded. ``authorization``,
``content-type``, and any other non-``x-*`` headers are dropped.
* Nothing is collected, persisted, or sent anywhere — the module only
attaches headers to an HTTP request that the caller was already going
to make. No telemetry, no out-of-band channel. (Diagnostic CVDIAG
breadcrumbs ARE logged via the stdlib ``logging`` module: header
PRESENCE plus a short value prefix only — never full header values.)
"""
from __future__ import annotations
import contextvars
import logging
import warnings
from typing import Any, Dict, Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
# CVDIAG correlation-header instrumentation tag for this integration. Each
# showcase backend that copies this shim sets a distinct framework tag so the
# CVDIAG breadcrumb trail identifies which backend captured/forwarded headers.
_CVDIAG_FRAMEWORK = "ag2"
# Correlation headers carried end-to-end through the showcase request chain.
_DIAG_RUN_ID_HEADER = "x-diag-run-id"
_DIAG_HOPS_HEADER = "x-diag-hops"
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
_TEST_ID_HEADER = "x-test-id"
def _cvdiag(
boundary: str,
headers: Dict[str, str],
*,
status: str,
hop: object = "-",
error: str = "",
) -> None:
"""Emit a single standardized CVDIAG breadcrumb line.
Logs ONLY header presence + a short value prefix (never full header
values). ``headers`` is the lowercased ``x-*`` header mapping for the
current request context.
"""
slug = headers.get(_AIMOCK_CONTEXT_HEADER)
run_id = headers.get(_DIAG_RUN_ID_HEADER, "none")
test_id = headers.get(_TEST_ID_HEADER, "none")
present = slug is not None
prefix = (slug or "")[:12]
logger.info(
"CVDIAG component=backend-%s boundary=%s run_id=%s slug=%s "
"header_present=%s header_value_prefix=%s hop=%s status=%s "
"test_id=%s error=%s",
_CVDIAG_FRAMEWORK,
boundary,
run_id,
slug if present else "MISSING",
"true" if present else "false",
prefix,
hop,
status,
test_id,
error,
)
# Per-request storage for the headers the application has asked to forward
# onto outbound LLM/provider calls.
_forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
"copilotkit_forwarded_headers"
)
# Marker used to identify hooks we have already installed so the install
# call is idempotent across repeated invocations on the same client.
_HOOK_MARKER = "_copilotkit_forwarded_header_hook"
# Bound on how deep we'll walk a ``._client`` chain looking for event_hooks.
# Modern provider SDKs (OpenAI, Anthropic, pydantic-ai wrappers, agno's
# OpenAIChat, strands' OpenAIModel) wrap their httpx client behind 2-4
# layers of ``._client`` indirection; 5 hops is enough headroom without
# risking pathological loops.
_MAX_CHAIN_DEPTH = 5
def set_forwarded_headers(headers: Dict[str, str]) -> None:
"""Record headers to forward onto outbound LLM/provider calls.
Only ``x-*`` prefixed headers are kept; everything else is dropped.
"""
filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith("x-")}
_forwarded_headers.set(filtered)
def get_forwarded_headers() -> Dict[str, str]:
"""Return the headers recorded for the current request context."""
return _forwarded_headers.get({})
class HeaderForwardingHTTPMiddleware(BaseHTTPMiddleware):
"""Starlette/FastAPI middleware that captures inbound ``x-*`` headers.
On every inbound HTTP request, copies all ``x-*`` prefixed headers
onto the per-request ContextVar so any outbound httpx call made
inside the request scope (the LLM call hop 2) sees them via
``get_forwarded_headers()`` and the installed httpx event hook.
"""
async def dispatch(self, request: Request, call_next) -> Response:
headers = {
k: v for k, v in request.headers.items() if k.lower().startswith("x-")
}
set_forwarded_headers(headers)
captured = {k.lower(): v for k, v in headers.items()}
_cvdiag(
"contextvar-capture",
captured,
status="ok" if _AIMOCK_CONTEXT_HEADER in captured else "miss",
)
return await call_next(request)
def _find_event_hooks_target(client: Any) -> Optional[Any]:
"""Walk ``._client`` chain looking for the first httpx-style event_hooks.
Returns the target object, or ``None`` if not found within
``_MAX_CHAIN_DEPTH`` hops.
"""
current = client
for _ in range(_MAX_CHAIN_DEPTH + 1):
if current is None:
return None
if hasattr(current, "event_hooks"):
return current
nxt = getattr(current, "_client", None)
if nxt is current or nxt is None:
return None
current = nxt
return None
def _is_async_httpx_target(target: Any) -> bool:
"""Best-effort detection: is this an httpx async client?
Detection is HIGH-CONFIDENCE when ``isinstance`` against the real
``httpx.AsyncClient`` / ``httpx.Client`` succeeds. The MRO name-only
fallback (matching a class literally named ``AsyncClient``) is
LOW-CONFIDENCE: a wrapped/duck-typed client whose class happens to be
named ``AsyncClient`` (or that is async but is NOT so named) can be
misclassified, which would install a sync hook on an async client (an
un-awaited coroutine → silent header drop) or vice versa. Each path
emits a CVDIAG breadcrumb tagged with the chosen confidence so a
misdetection is greppable in the logs. The return values themselves are
unchanged — only the diagnostics are new.
"""
try:
import httpx
if isinstance(target, httpx.AsyncClient):
_cvdiag(
"async-detect",
{},
status="ok",
error="path=isinstance-async confidence=high",
)
return True
if isinstance(target, httpx.Client):
_cvdiag(
"async-detect",
{},
status="ok",
error="path=isinstance-sync confidence=high",
)
return False
except ImportError: # pragma: no cover
pass
# Fall back to exact class-name match for wrapped/duck-typed clients.
# LOW-CONFIDENCE: this can misdetect async-vs-sync for oddly-named
# wrappers; the breadcrumb records the fallback so a wrong hook kind is
# traceable to this path.
for cls in type(target).__mro__:
if cls.__name__ == "AsyncClient":
_cvdiag(
"async-detect",
{},
status="ok",
error=(
"path=mro-name-match confidence=low "
f"target_type={type(target).__name__}"
),
)
return True
_cvdiag(
"async-detect",
{},
status="ok",
error=(f"path=default-sync confidence=low target_type={type(target).__name__}"),
)
return False
def _inject_diag_hop(request: Any, headers: Dict[str, str]) -> None:
"""Append this backend's hop tag to ``x-diag-hops`` on the outbound
request and emit the ``outbound-llm`` CVDIAG breadcrumb.
``x-diag-hops`` is a comma-separated trail of the backends that touched
the request; appending ``backend-<framework>`` here records that this
integration forwarded the correlation headers onto the LLM/provider
call. ``x-diag-run-id`` is carried verbatim (already copied above via
the ``headers`` loop) the same way ``x-aimock-context`` is.
GATED on diagnostic-header presence: the breadcrumb append and the
outbound CVDIAG log fire ONLY when the forwarded headers carry a
diagnostic header (``x-diag-run-id`` OR ``x-aimock-context``). When
NEITHER is present this is a no-op, so the outbound request is
byte-identical to pre-instrumentation behavior.
"""
if _DIAG_RUN_ID_HEADER not in headers and _AIMOCK_CONTEXT_HEADER not in headers:
return
hop_tag = f"backend-{_CVDIAG_FRAMEWORK}"
existing = headers.get(_DIAG_HOPS_HEADER, "")
trail = [h for h in (existing.split(",") if existing else []) if h]
trail.append(hop_tag)
new_hops = ",".join(trail)
request.headers[_DIAG_HOPS_HEADER] = new_hops
_cvdiag(
"outbound-llm",
headers,
status="ok" if _AIMOCK_CONTEXT_HEADER in headers else "miss",
hop=len(trail),
)
def install_httpx_hook(client: Any) -> None:
"""Attach an httpx request event hook to ``client``'s httpx client.
Walks the ``._client`` chain to find the first object with an
``event_hooks`` mapping, then appends a request hook that copies the
ContextVar-recorded headers onto each outbound request.
Works with OpenAI / Anthropic / pydantic-ai / agno / strands client
wrappers (all wrap httpx internally), as well as raw
``httpx.Client`` / ``httpx.AsyncClient`` instances.
Idempotent: a marker attribute on the installed callable prevents
double-installation on the same target.
"""
target = _find_event_hooks_target(client)
if target is None:
msg = (
f"install_httpx_hook: client of type {type(client).__name__} has no "
"recognized event_hooks attribute; x-* headers will NOT be forwarded "
"for this client"
)
warnings.warn(msg, stacklevel=2)
# warnings.warn is invisible in most prod runtimes (filtered/once);
# ALSO log at WARNING so a non-forwarding client surfaces.
logger.warning("CVDIAG boundary=hook-install status=error error=%s", msg)
_cvdiag("hook-install", {}, status="error", error="no-event-hooks-target")
return
request_hooks = target.event_hooks.get("request", [])
# Idempotency: don't double-install on the same target.
for existing in request_hooks:
if getattr(existing, _HOOK_MARKER, False):
return
is_async = _is_async_httpx_target(target)
if is_async:
async def _inject_headers_async(request):
headers = get_forwarded_headers()
for key, value in headers.items():
request.headers[key] = value
_inject_diag_hop(request, headers)
setattr(_inject_headers_async, _HOOK_MARKER, True)
request_hooks.append(_inject_headers_async)
else:
def _inject_headers(request):
headers = get_forwarded_headers()
for key, value in headers.items():
request.headers[key] = value
_inject_diag_hop(request, headers)
setattr(_inject_headers, _HOOK_MARKER, True)
request_hooks.append(_inject_headers)
target.event_hooks["request"] = request_hooks
# Module-scope sentinel preventing repeated global patching.
_GLOBAL_HTTPX_PATCHED = False
def install_global_httpx_hook() -> None:
"""Patch ``httpx.Client`` / ``httpx.AsyncClient`` so EVERY future
instance auto-attaches the forwarded-header hook on construction.
Use this when the LLM client is buried behind opaque framework
machinery (AG2's ``ConversableAgent`` constructs OpenAI clients
lazily, CrewAI uses litellm which constructs httpx clients per-call,
etc.) and there is no single client instance to call
:func:`install_httpx_hook` on at startup.
Safe to call at import time. Idempotent: a module-scope sentinel
prevents repeated patching, and the per-instance idempotency check
in :func:`install_httpx_hook` prevents double-hooking on each new
client. Pre-existing ``httpx.Client`` instances are not retroactively
hooked — only those constructed AFTER this call.
"""
global _GLOBAL_HTTPX_PATCHED
if _GLOBAL_HTTPX_PATCHED:
return
try:
import httpx
except ImportError: # pragma: no cover
return
_orig_sync_init = httpx.Client.__init__
_orig_async_init = httpx.AsyncClient.__init__
def _patched_sync_init(self, *args, **kwargs):
_orig_sync_init(self, *args, **kwargs)
try:
install_httpx_hook(self)
except Exception as exc: # pragma: no cover - never break client construction
# A failed hook install means x-aimock-context silently never
# forwards (the whole point of this shim). Keep swallowing the
# exception so client construction never breaks, but FAIL LOUD:
# log at ERROR with the FULL detail (not 80-char-truncated) so a
# broken install is visible, not buried at INFO.
detail = f"sync-init {type(exc).__name__}: {exc}"
logger.error(
"CVDIAG boundary=hook-install status=error error=%s",
detail,
exc_info=True,
)
_cvdiag("hook-install", {}, status="error", error=detail)
def _patched_async_init(self, *args, **kwargs):
_orig_async_init(self, *args, **kwargs)
try:
install_httpx_hook(self)
except Exception as exc: # pragma: no cover
# See _patched_sync_init: swallow to protect construction, but
# FAIL LOUD at ERROR with full detail so a broken install (which
# silently drops x-aimock-context forwarding) is visible.
detail = f"async-init {type(exc).__name__}: {exc}"
logger.error(
"CVDIAG boundary=hook-install status=error error=%s",
detail,
exc_info=True,
)
_cvdiag("hook-install", {}, status="error", error=detail)
httpx.Client.__init__ = _patched_sync_init
httpx.AsyncClient.__init__ = _patched_async_init
_GLOBAL_HTTPX_PATCHED = True
# Module-scope sentinel preventing repeated executor patching.
_EXECUTOR_CTXVAR_PATCHED = False
def install_executor_contextvar_propagation() -> None:
"""Patch ``asyncio.events.AbstractEventLoop.run_in_executor`` so the
parent task's ContextVars are propagated into the executor thread.
Why this exists
---------------
autogen's ``ConversableAgent.a_generate_oai_reply`` dispatches the
underlying (sync) OpenAI/LiteLLM call onto the default thread pool
via ``loop.run_in_executor(None, functools.partial(...))``. The stock
``run_in_executor`` does NOT copy the caller's :pep:`567` context to
the worker thread — so the :class:`HeaderForwardingHTTPMiddleware`
ContextVar (set on the inbound request task) is empty inside the
executor, and our outbound httpx hook sees no headers to forward.
``asyncio.to_thread`` (Python 3.9+) does copy context the right way;
this patch makes plain ``run_in_executor`` behave the same. It only
affects functions submitted via ``run_in_executor`` — coroutines and
other constructs are unaffected.
Safe to call at import time. Idempotent via a module-scope sentinel.
Scope caveat: this patches ``asyncio.base_events.BaseEventLoop`` only.
Pre-existing *stdlib asyncio* event-loop instances inherit the patch
(``run_in_executor`` is defined on ``BaseEventLoop`` and resolved
per-call via normal method resolution). It is INERT under uvloop —
uvloop's loop does not subclass ``BaseEventLoop`` and resolves
``run_in_executor`` from its own C implementation, so the stdlib
method this patch rebinds is never consulted. Under uvloop, ContextVar
propagation into ``run_in_executor`` worker threads is NOT provided by
this shim.
"""
global _EXECUTOR_CTXVAR_PATCHED
if _EXECUTOR_CTXVAR_PATCHED:
return
import asyncio.base_events as _base_events
_orig_run_in_executor = _base_events.BaseEventLoop.run_in_executor
def _patched_run_in_executor(self, executor, func, *args):
# Capture the CURRENT task's context at submit time, then run the
# submitted callable inside that context on the worker thread.
ctx = contextvars.copy_context()
def _ctx_wrapper(*a, **kw):
return ctx.run(func, *a, **kw)
# Preserve __name__/__qualname__ for nicer tracebacks where possible.
try:
_ctx_wrapper.__wrapped__ = func # type: ignore[attr-defined]
except Exception: # pragma: no cover
pass
return _orig_run_in_executor(self, executor, _ctx_wrapper, *args)
_base_events.BaseEventLoop.run_in_executor = _patched_run_in_executor
_EXECUTOR_CTXVAR_PATCHED = True
@@ -0,0 +1,390 @@
"""AG-UI → autogen multimodal content normalization for the AG2 backend.
Problem
-------
The ``multimodal`` showcase cell sends user messages whose ``content`` is a
list of AG-UI ``InputContent`` parts. The shapes that actually arrive on
the wire are:
* Modern AG-UI image:
``{"type": "image", "source": {"type": "data" | "url", "value": "...",
"mimeType" | "mime_type": "image/png"}}``
* Modern AG-UI document (PDF, etc):
``{"type": "document", "source": {...}}``
* Legacy AG-UI binary mirror (appended by
``src/app/demos/multimodal/legacy-converter-shim.tsx``):
``{"type": "binary", "mimeType": "image/png", "data": "..." | "url": "..."}``
AG2's ``ConversableAgent`` runs every user message through
``autogen.code_utils.content_str``, which only accepts content-part types
``{"text", "input_text", "image_url", "input_image", "function",
"tool_call", "tool_calls"}``. Any other ``type`` raises
``ValueError("Wrong content format: unknown type <type> within the
content")`` BEFORE the request reaches the model — observed live in the
D6 ``multimodal`` probe (image turn errored out with that message; see
commit d8a0a25db for the symptom report and the original NSF
quarantine).
Fix
---
``NormalizingAGUIStream`` subclasses ``AGUIStream`` and overrides
``dispatch`` to normalise each user message's content list so AG-UI
image / document / binary parts become OpenAI Chat Completions
``image_url`` parts (which autogen accepts and forwards to the
vision-capable model natively).
The normalization runs AFTER ``RunAgentInput`` Pydantic parsing (which
accepts the standard AG-UI ``image``/``document``/``binary`` content
types) and BEFORE the messages are passed to ``AgentService``, which
serialises them via ``model_dump()`` into raw dicts and passes them to
``ConversableAgent``. That is the correct interception point: too early
(before Pydantic) would require rewriting ``image_url`` into the AG-UI
body, which ``RunAgentInput`` rejects; too late (inside ConversableAgent)
would require patching autogen internals.
Conversions:
* ``{"type": "image", "source": {"type": "data", value, mime_type}}`` →
``{"type": "image_url", "image_url": {"url": "data:<mime>;base64,<value>"}}``
* ``{"type": "image", "source": {"type": "url", value}}`` →
``{"type": "image_url", "image_url": {"url": value}}``
* ``{"type": "document", "source": ...}`` → ``image_url`` with the
document's mime preserved (data:application/pdf;base64,...). The vision
model still cannot natively read PDFs, but the request reaches the model
instead of being rejected upstream.
* ``{"type": "binary", mimeType, data | url}`` → ``image_url`` (the
legacy-shim parts ride through cleanly).
* ``{"type": "text", ...}`` and already-normalised ``image_url`` parts
pass through unchanged (idempotent on no-op turns).
Failure path: any normalization error is logged at WARNING and the
original message is replayed unchanged — autogen's own ``ValueError``
fires verbatim, preserving the failure surface.
The normalizer is mounted ONLY on the ``multimodal_app`` sub-app
(``agents/multimodal_agent.py``), not on the global FastAPI server in
``agent_server.py`` — keeping the blast radius scoped to the one route
that actually sees image content parts.
"""
from __future__ import annotations
import logging
from typing import Any, AsyncIterator
from autogen.ag_ui import AGUIStream, RunAgentInput
logger = logging.getLogger(__name__)
_IMAGE_URL_TYPE = "image_url"
_TEXT_TYPE = "text"
def _build_data_url(mime: str, payload: str) -> str:
"""Assemble a ``data:<mime>;base64,<payload>`` URL.
The OpenAI Chat Completions ``image_url`` part accepts either a
plain ``https://`` URL or an inline base64 data URL — both flow
through autogen's ``content_str`` allowed-types gate as
``image_url``. Building a data URL from the AG-UI ``data`` source
keeps the inline payload intact end-to-end.
"""
return f"data:{mime};base64,{payload}"
def _normalize_modern_part(part: dict[str, Any]) -> dict[str, Any] | None:
"""Convert a modern AG-UI ``image`` / ``document`` part to ``image_url``.
Returns ``None`` if the shape is unrecognised — the caller passes
the original part through unchanged in that case.
Modern AG-UI content shape (see ``ag_ui.core.types.ImageInputContent``):
``{"type": "image" | "document",
"source": {"type": "data" | "url",
"value": "<base64>" | "<https://...>",
"mime_type" | "mimeType": "..."}}``
"""
source = part.get("source")
if not isinstance(source, dict):
return None
value = source.get("value")
if not isinstance(value, str) or not value:
return None
# The AG-UI pydantic model uses ``mime_type``; the legacy converter
# shim and some hand-rolled payloads use ``mimeType``. Accept both
# so a frontend running either schema version round-trips cleanly.
mime = source.get("mime_type") or source.get("mimeType") or ""
if not isinstance(mime, str) or not mime:
# Fall back to a generic mime so the URL is at least well-formed
# data:URL syntax. The model side will likely ignore an unknown
# mime, but autogen's allowed-types gate only inspects ``type``.
mime = "application/octet-stream"
src_type = source.get("type")
if src_type == "url":
# Pass URL-source values through as the image_url url directly.
return {"type": _IMAGE_URL_TYPE, "image_url": {"url": value}}
if src_type == "data":
return {
"type": _IMAGE_URL_TYPE,
"image_url": {"url": _build_data_url(mime, value)},
}
return None
def _normalize_legacy_binary_part(part: dict[str, Any]) -> dict[str, Any] | None:
"""Convert a legacy AG-UI ``binary`` part to ``image_url``.
The frontend at ``src/app/demos/multimodal/legacy-converter-shim.tsx``
APPENDS one of these alongside every modern ``image``/``document``
part to feed the @ag-ui/langgraph converter (LangChain integrations
only understand the legacy shape). Those appended parts ride along
on the same payload that hits the AG2 backend, and autogen also
rejects ``binary`` as an unknown content type. Normalising them
here turns the round-trip into a no-op for AG2 instead of a hard
rejection.
Shape:
``{"type": "binary", "mimeType": "<mime>",
"data": "<base64>" | "url": "<https://...>"}``
"""
mime = part.get("mimeType") or part.get("mime_type") or "application/octet-stream"
if not isinstance(mime, str):
mime = "application/octet-stream"
data = part.get("data")
if isinstance(data, str) and data:
return {
"type": _IMAGE_URL_TYPE,
"image_url": {"url": _build_data_url(mime, data)},
}
url = part.get("url")
if isinstance(url, str) and url:
return {"type": _IMAGE_URL_TYPE, "image_url": {"url": url}}
return None
def _normalize_content_part(part: Any) -> Any:
"""Return an autogen-acceptable content part for ``part``.
Recognised conversions:
* ``{"type": "image", "source": ...}`` → ``image_url``
* ``{"type": "document", "source": ...}`` → ``image_url`` (data
URL with the original mime; vision model gets the raw bytes
and the system prompt steers it on what to do with them)
* ``{"type": "binary", ...}`` → ``image_url``
Everything else (``text``, already-normalised ``image_url``,
unknown shapes) passes through untouched. Returning the original
part on no-op keeps the rewrite idempotent and preserves any extra
keys autogen / the model might consume.
"""
if not isinstance(part, dict):
return part
part_type = part.get("type")
if part_type in ("image", "document", "audio", "video"):
normalized = _normalize_modern_part(part)
if normalized is not None:
return normalized
# Recognised modality with an unrecognised source — log and
# drop to a plain text placeholder so autogen accepts the
# part instead of choking. Without this, an empty/malformed
# source would survive as ``image``/``document`` and trip the
# exact ValueError we're working around.
logger.warning(
"[ag2:multimodal-normalize] dropping unrecognised %s source "
"shape; replacing with text placeholder",
part_type,
)
return {
"type": _TEXT_TYPE,
"text": f"[unreadable {part_type} attachment]",
}
if part_type == "binary":
normalized = _normalize_legacy_binary_part(part)
if normalized is not None:
return normalized
logger.warning(
"[ag2:multimodal-normalize] dropping unrecognised binary shape; "
"replacing with text placeholder",
)
return {
"type": _TEXT_TYPE,
"text": "[unreadable binary attachment]",
}
return part
def normalize_messages_for_autogen(messages: Any) -> Any:
"""Rewrite a list of message dicts so AG-UI multimodal parts are
converted to autogen-acceptable ``image_url`` parts.
Accepts the dict-serialised form produced by
``RunAgentInput.messages[i].model_dump(exclude_none=True)`` — the
same dicts that ``run_stream`` in autogen's AG-UI adapter passes to
``AgentService``.
Returns the input value untouched if it is not the expected list
shape. Otherwise returns a NEW list with rewritten user-message
content; non-user messages are forwarded as-is.
The function is pure: it never mutates the input.
"""
if not isinstance(messages, list):
return messages
rewritten: list[Any] = []
for msg in messages:
if not isinstance(msg, dict):
rewritten.append(msg)
continue
if msg.get("role") != "user":
rewritten.append(msg)
continue
content = msg.get("content")
if not isinstance(content, list):
# String content (plain text) and ``None`` pass through
# untouched. Autogen accepts both.
rewritten.append(msg)
continue
new_content = [_normalize_content_part(part) for part in content]
if new_content == content:
# No-op for this message — preserve the original dict so we
# never accidentally drop a key the downstream app reads.
rewritten.append(msg)
continue
new_msg = dict(msg)
new_msg["content"] = new_content
rewritten.append(new_msg)
return rewritten
class NormalizingAGUIStream(AGUIStream):
"""``AGUIStream`` subclass that normalises AG-UI multimodal content.
Overrides ``dispatch`` to call ``normalize_messages_for_autogen``
on the parsed ``RunAgentInput.messages`` (as serialised dicts) AFTER
Pydantic validation and BEFORE ``AgentService`` processes them. This
is the only correct interception point:
* Too early (ASGI body rewrite before Pydantic): ``RunAgentInput``
rejects ``image_url`` because it is not an AG-UI standard content
type — the validator only accepts ``image``, ``document``,
``binary``, ``text``, ``audio``, ``video``.
* Too late (inside ConversableAgent): requires patching autogen
internals that can change across versions.
The override patches ``autogen.ag_ui.adapter.run_stream`` at call
time by supplying pre-normalised messages via a thin
``RequestMessage`` shim, replacing only the ``messages`` field in
the ``AGStreamInput`` passed to the inherited ``dispatch`` machinery.
"""
async def dispatch(
self,
incoming: RunAgentInput,
*,
context: dict[str, Any] | None = None,
accept: str | None = None,
) -> AsyncIterator[str]:
# Serialise all messages to dicts (same as run_stream does) then
# normalise, then re-inject via a patched incoming object so the
# rest of the dispatch machinery sees image_url parts instead of
# AG-UI image/document/binary parts.
raw_msgs: list[dict[str, Any]] | None = None
try:
raw_msgs = [m.model_dump(exclude_none=True) for m in incoming.messages]
normalised_msgs = normalize_messages_for_autogen(raw_msgs)
except Exception as exc: # noqa: BLE001 — log + fall back to original
logger.warning(
"[ag2:multimodal-normalize] pre-dispatch normalization failed "
"(%s); forwarding original messages to autogen",
exc,
exc_info=True,
)
normalised_msgs = None
if (
normalised_msgs is not None
and raw_msgs is not None
and normalised_msgs is not raw_msgs
):
# Re-validate the normalised dicts back into Pydantic Message
# objects so the rest of AGUIStream.dispatch / run_stream can
# work with a properly typed RunAgentInput.
# We use model_validate (not model_validate_json) since we already
# have a Python dict. The normalised content uses image_url parts,
# which are NOT in the AG-UI InputContent union — so we re-validate
# just the message list using the raw dict form and pass it via a
# reconstructed RunAgentInput.
#
# IMPORTANT: we pass the normalised dicts as plain dicts; autogen's
# run_stream calls model_dump() on each message in
# command.incoming.messages. To avoid a double round-trip we
# instead *monkey-patch the model_dump contract* by building a
# lightweight wrapper list that returns the pre-normalised dict on
# model_dump() — keeping the rest of dispatch's typing clean.
incoming = _PatchedRunAgentInput(incoming, normalised_msgs)
# Delegate to the parent implementation with the (possibly patched)
# incoming object. AGUIStream.dispatch is a normal async generator so
# we must use "yield from" semantics via the async iterator protocol.
async for chunk in super().dispatch(incoming, context=context, accept=accept):
yield chunk
class _DictMessage:
"""Minimal message wrapper that returns a pre-computed dict on model_dump.
``run_stream`` in autogen's adapter calls
``m.model_dump(exclude_none=True)`` on each message in
``command.incoming.messages``. This wrapper satisfies that call
without the round-trip overhead of re-parsing the normalised dict
back through Pydantic (which would fail anyway since ``image_url``
is not an AG-UI content type).
"""
__slots__ = ("_d",)
def __init__(self, d: dict[str, Any]) -> None:
self._d = d
def model_dump(self, *, exclude_none: bool = False) -> dict[str, Any]: # noqa: ARG002
return self._d
class _PatchedRunAgentInput:
"""Thin wrapper around ``RunAgentInput`` that substitutes a pre-normalised
message list while forwarding all other attribute access to the original.
``AGUIStream.dispatch`` and ``run_stream`` read ``incoming.messages``,
``incoming.tools``, ``incoming.thread_id``, ``incoming.run_id``,
``incoming.state``, ``incoming.context``, and ``incoming.forwarded_props``
(plus optionally ``incoming.resume``). We override only ``messages``; all
others fall through to the real ``RunAgentInput`` object.
"""
__slots__ = ("_real", "_messages")
def __init__(
self,
real: RunAgentInput,
normalised_dicts: list[dict[str, Any]],
) -> None:
object.__setattr__(self, "_real", real)
object.__setattr__(
self,
"_messages",
[_DictMessage(d) for d in normalised_dicts],
)
@property
def messages(self) -> list[_DictMessage]:
return object.__getattribute__(self, "_messages")
def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_real"), name)
__all__ = [
"NormalizingAGUIStream",
"normalize_messages_for_autogen",
]
@@ -0,0 +1,311 @@
"""Per-request context capture for AG2 showcase backends.
Problem
-------
The AG2 showcase backends construct a single module-level
``ConversableAgent`` and re-use it across every inbound request (see
``agents/agent.py`` and ``agents/a2ui_dynamic.py``). Autogen mutates the
agent's ``chat_messages`` dict in place per turn, which means reading
"the latest user message" off ``agent.chat_messages`` is a cross-request
data race under any concurrency: a second request landing while the
first is still mid-tool-call observes the first request's messages.
The R2-A3 fix-cycle resolves this by reading the latest user prompt
directly from the per-request ``RunAgentInput.messages`` payload (the
runtime-supplied per-request body) instead of from autogen's shared
``chat_messages`` state. This module captures that payload at the HTTP
request boundary and exposes it via a ``contextvars.ContextVar`` so deep
tool-handler code (e.g. ``generate_a2ui``) can read it without threading
parameters through autogen's internal driver.
Mechanics
---------
1. ``RequestUserMessageMiddleware`` (Starlette/FastAPI ``BaseHTTPMiddleware``)
runs on every inbound POST. It reads the body (Starlette caches the
body internally so downstream handlers still see it), parses
``RunAgentInput.messages`` from the JSON payload, walks the list in
chronological order, and stores the most recent ``role == "user"``
message text in a per-request ``ContextVar``.
2. ``get_latest_user_message()`` returns the captured text (or ``""``).
Failures are intentionally NON-fatal: any parse error (non-JSON body,
missing ``messages``, schema drift, etc.) is logged at WARNING with the
exception type/message, and the ContextVar is set to ``""`` so callers
fall back to their hardcoded default. This is the R2-A2 fix discipline:
visibility into the fallback path rather than silent swallowing.
"""
from __future__ import annotations
import contextvars
import json
import logging
from typing import Any, Optional
from starlette.types import ASGIApp, Message, Receive, Scope, Send
logger = logging.getLogger(__name__)
_latest_user_message: contextvars.ContextVar[str] = contextvars.ContextVar(
"ag2_latest_user_message",
default="",
)
def get_latest_user_message() -> str:
"""Return the latest user message text captured for the current request.
Returns ``""`` when no message was captured (non-AG-UI request, parse
failure, empty ``messages`` array, an actually-empty user message,
etc.) — callers should treat the empty string as "fall back to the
hardcoded default prompt". The distinction between "user message
present but empty" and "no user message in payload" is preserved at
the ``_extract_latest_user_text`` boundary via ``Optional[str]`` but
collapsed at the ContextVar boundary since downstream callers all
fall back the same way.
"""
return _latest_user_message.get()
def _extract_latest_user_text(payload: Any) -> Optional[str]:
"""Walk a parsed ``RunAgentInput``-shaped dict for the last user message.
Iterates ``payload["messages"]`` in chronological order (the AG-UI
contract: the runtime sends the conversation history in order) and
returns the ``content`` of the last entry whose ``role == "user"``.
Return semantics:
* ``None`` — no user message present in the payload at all
(non-dict payload, missing/empty ``messages`` list, no entry
with ``role == "user"``, or every user entry had an
unrecognised content shape).
* ``""`` — a user message IS present but its content is the
empty string (legitimate empty turn from the runtime).
* non-empty ``str`` — the actual latest user text.
Distinguishing ``None`` from ``""`` lets the caller decide whether
to log "missing" vs "present but empty"; collapsing them at this
boundary would force a guess. Schema-drift early-returns log at
WARNING here (rather than via the caller wrapping in try/except)
because no exception is raised — there's nothing for the caller to
catch.
"""
if not isinstance(payload, dict):
logger.warning(
"[ag2:request-context] payload is not a dict (got %s); "
"no user message extractable",
type(payload).__name__,
)
return None
messages = payload.get("messages")
if not isinstance(messages, list):
logger.warning(
"[ag2:request-context] payload.messages missing or not a list "
"(got %s); no user message extractable",
type(messages).__name__,
)
return None
latest: Optional[str] = None
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
# Present-but-empty is a legitimate value; set unconditionally
# so the caller can distinguish "" (empty turn) from None
# (no user message at all).
latest = content
elif isinstance(content, list):
# Multimodal content: join the text parts, mirroring the
# coercion in reasoning_agent._coerce_content. An empty
# parts list collapses to "" — still "present but empty".
parts: list[str] = []
for part in content:
if isinstance(part, dict):
text = part.get("text")
elif hasattr(part, "text"):
text = getattr(part, "text", None)
else:
text = None
if isinstance(text, str):
parts.append(text)
latest = "".join(parts)
# Unknown content shapes (None, int, …) leave ``latest`` untouched
# so a later well-formed user message still wins.
if latest is None:
logger.warning(
"[ag2:request-context] no user message found in payload "
"(messages len=%d); leaving latest-user-message empty",
len(messages),
)
elif latest == "":
logger.warning("[ag2:request-context] latest user message is present but empty")
return latest
class RequestUserMessageMiddleware:
"""Capture the latest user message from each inbound ``RunAgentInput`` POST.
Implemented as a raw ASGI middleware (NOT
``starlette.middleware.base.BaseHTTPMiddleware``) so we can buffer the
inbound request body and replay it to the downstream ASGI app via a
wrapped ``receive`` callable. ``BaseHTTPMiddleware`` does not re-emit
consumed body chunks to the inner app, which would silently truncate
the request to autogen / AG-UI.
For POST requests with a JSON-ish body, parses ``RunAgentInput.messages``
and stores the chronologically last ``role == "user"`` message in a
per-request ContextVar. Non-POST requests and non-HTTP scopes pass
through untouched. Parse failures are logged at WARNING (R2-A2
visibility) and leave the ContextVar at its empty-string default.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
# R5-A2: Unconditionally reset the ContextVar at __call__ entry,
# BEFORE any branching by scope type or method. Autogen's
# ``install_executor_contextvar_propagation`` makes
# ``ThreadPoolExecutor`` workers inherit the dispatching request's
# Context, and those workers are reused across requests. Without
# this reset, a non-POST request, an empty-body POST, or any path
# that doesn't reach the body-parse ``.set(...)`` below would
# inherit whatever value the worker's prior request left in the
# ContextVar — leaking the previous request's prompt into this
# one. The body-parse path further down overrides this default
# when a real user message is parsed.
_latest_user_message.set("")
if scope["type"] != "http" or scope.get("method") != "POST":
await self.app(scope, receive, send)
return
# Buffer the entire request body so we can both inspect it AND
# replay it to the inner ASGI app via a wrapped ``receive``.
body_chunks: list[bytes] = []
more_body = True
client_disconnected = False
while more_body:
message = await receive()
if message["type"] == "http.request":
body_chunks.append(message.get("body", b"") or b"")
more_body = bool(message.get("more_body", False))
elif message["type"] == "http.disconnect":
# Client hung up before the body fully arrived. Do NOT
# invoke the downstream app with a truncated body: that
# would feed autogen / AG-UI half a JSON document and
# surface as a confusing parse error in the agent rather
# than the actual root cause. Short-circuit instead and
# log so the truncation is visible in the operator
# dashboard.
client_disconnected = True
more_body = False
else:
# Unknown message kind for an HTTP scope — pass it
# through unchanged and stop buffering.
more_body = False
raw = b"".join(body_chunks)
if client_disconnected:
logger.warning(
"[ag2:request-context] client disconnected before request "
"body fully received (%d bytes buffered); short-circuiting "
"without invoking downstream app",
len(raw),
)
return
if raw:
# NOTE: ``_extract_latest_user_text`` itself does NOT raise
# on shape violations — it logs at WARNING and returns
# ``None``. The try/except here is strictly for decoding
# failures (``json.loads`` / UTF-8). A previous version
# wrapped a broader ``(AttributeError, KeyError, TypeError)``
# branch around the extractor call, but the extractor never
# raises those — so the branch was dead code that hid the
# real source of any shape-drift signal. The extractor now
# owns its own logging on those paths.
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
logger.warning(
"[ag2:request-context] body is not valid JSON; "
"leaving latest-user-message empty: %s",
exc,
)
_latest_user_message.set("")
except UnicodeDecodeError as exc:
# ``json.loads`` accepts ``bytes`` and decodes them as
# UTF-8 internally; a non-UTF-8 payload (rare but
# possible from a misbehaving client) raises
# ``UnicodeDecodeError`` rather than ``JSONDecodeError``.
# Without this branch the exception escapes and crashes
# the request silently from the operator's perspective.
logger.warning(
"[ag2:request-context] body is not valid UTF-8; "
"leaving latest-user-message empty: %s",
exc,
exc_info=True,
)
_latest_user_message.set("")
else:
text = _extract_latest_user_text(payload)
# Collapse None → "" at the ContextVar boundary: callers
# all fall back to the hardcoded default the same way,
# so the present-but-empty vs missing distinction has
# already done its job via the extractor's WARNING logs.
_latest_user_message.set(text if text is not None else "")
replayed = False
original_receive = receive
async def _replay_receive() -> Message:
nonlocal replayed
if not replayed:
replayed = True
return {
"type": "http.request",
"body": raw,
"more_body": False,
}
# R7-A1: After the buffered body is delivered once, the inner
# app may keep calling ``receive()`` for the lifetime of the
# response — SSE / AG-UI streams in particular poll
# ``receive()`` (via Starlette's ``listen_for_disconnect``) to
# detect client disconnect. Per the ASGI spec, ANY
# ``http.disconnect`` message terminates the response stream:
# an earlier revision synthesised a single disconnect
# immediately after body drain and that one synthesised
# message was enough to cancel the SSE response prematurely.
# The correct behaviour is to NEVER synthesise disconnect
# post-drain and instead await ``original_receive()``, which
# uvicorn blocks on until the REAL client ``http.disconnect``
# arrives. That is precisely the long-poll semantics SSE /
# AG-UI streams require.
message = await original_receive()
# Defensive: uvicorn should not deliver further
# ``http.request`` messages after the body is drained (the
# buffering loop above consumed every chunk until
# ``more_body=False``), but the ASGI spec is not strictly
# enforced by every server. Log and continue awaiting so the
# inner app only ever observes ``http.disconnect`` (or other
# legitimate post-body messages) on this code path.
while message.get("type") == "http.request":
logger.warning(
"[ag2:request-context] unexpected http.request after "
"body drain (more_body=%s, body_len=%d); ignoring and "
"awaiting real disconnect",
message.get("more_body"),
len(message.get("body", b"") or b""),
)
message = await original_receive()
return message
await self.app(scope, _replay_receive, send)
@@ -0,0 +1,188 @@
"""AG2 agent for the Declarative Generative UI (A2UI Dynamic Schema) demo.
Mirrors the langgraph-python `a2ui_dynamic.py` pattern: the agent owns the
`generate_a2ui` tool explicitly. When called, it invokes a secondary LLM
bound to `render_a2ui` (tool_choice forced) using the registered client
catalog injected via the runtime's `copilotkit.context`. The tool result
returns an `a2ui_operations` container which the runtime's A2UI middleware
detects and forwards to the frontend renderer.
The dedicated runtime route (`api/copilotkit-declarative-gen-ui/route.ts`)
sets `injectA2UITool: false` so the runtime does not double-bind a second
A2UI tool on top of this one.
"""
from __future__ import annotations
import json
import logging
from typing import cast
import openai
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream # type: ignore[import-not-found] # runtime-only submodule (ag2[ag-ui] extra); not present in static type stubs
from fastapi import FastAPI
from openai.types.chat import ChatCompletionFunctionToolParam
from openai.types.shared_params import FunctionDefinition
from tools import (
build_a2ui_operations_from_tool_call,
RENDER_A2UI_TOOL_SCHEMA,
)
from ._header_forwarding import get_forwarded_headers
from ._request_context import get_latest_user_message
logger = logging.getLogger(__name__)
# Module-level async client: re-used across requests (httpx connection pool is
# thread-safe). Using AsyncOpenAI inside an `async def` avoids blocking the
# ASGI event loop on the secondary LLM call.
_async_openai_client = openai.AsyncOpenAI()
SYSTEM_PROMPT = (
"You are a demo assistant for Declarative Generative UI (A2UI — Dynamic "
"Schema). Whenever a response would benefit from a rich visual — a "
"dashboard, status report, KPI summary, card layout, info grid, a "
"pie/donut chart of part-of-whole breakdowns, a bar chart comparing "
"values across categories, or anything more structured than plain text — "
"call `generate_a2ui` to draw it. The registered catalog includes "
"`Card`, `StatusBadge`, `Metric`, `InfoRow`, `PrimaryButton`, `PieChart`, "
"and `BarChart` (in addition to the basic A2UI primitives). Prefer "
"`PieChart` for part-of-whole breakdowns (sales by region, traffic "
"sources, portfolio allocation) and `BarChart` for comparisons across "
"categories (quarterly revenue, headcount by team, signups per month). "
"`generate_a2ui` takes no arguments and handles the rendering "
"automatically. Keep chat replies to one short sentence; let the UI do "
"the talking."
)
async def generate_a2ui() -> str:
"""Generate dynamic A2UI components based on the conversation.
Takes NO arguments. The outer agent calls this tool with empty
arguments (``{}``); the per-request user prompt is read from the
``RequestUserMessageMiddleware`` ContextVar (see ``_request_context``)
rather than threaded through a tool parameter. This mirrors the
langgraph-python sibling, whose ``generate_a2ui`` also takes no args
(``a2ui_dynamic.py``), and keeps the tool schema aligned with the D6
fixtures, which emit ``generate_a2ui`` with ``arguments="{}"``. A
required ``context`` parameter here would make pydantic reject every
empty-args call and drive the outer agent into a retry hot loop.
A secondary LLM designs the UI schema and data using the `render_a2ui`
tool schema. The result is returned as an `a2ui_operations` container
for the runtime A2UI middleware to detect and forward to the frontend.
"""
# A4 / R2-A3: thread the latest user prompt from the outer conversation
# into the inner call so each pill's request body is byte-distinct
# (without this, all 4 declarative pills produce IDENTICAL wire payloads
# because the outer agent calls generate_a2ui with arguments="{}" →
# context defaults → system message is constant, and the user message
# below is hardcoded).
#
# The prompt is read from a per-request ContextVar populated by
# ``RequestUserMessageMiddleware`` at the inbound HTTP boundary — NOT
# from ``agent.chat_messages`` (which is shared module-level mutable
# state racing across concurrent requests). If the middleware did not
# capture anything (non-AG-UI request, parse failure already logged at
# WARNING) we fall back to the original hardcoded prompt so the inner
# LLM call still produces a sensible default.
user_prompt = get_latest_user_message() or (
"Generate a dynamic A2UI dashboard based on the conversation."
)
# The inner-call system message is constant; per-pill distinctness comes
# from ``user_prompt`` above (the outer conversation's latest user
# message, captured per-request). Previously this was the outer agent's
# ``context`` tool argument, but the outer agent calls ``generate_a2ui``
# with empty args ``{}`` (see the no-arg signature + the D6 fixtures),
# so a required ``context`` param only produced a pydantic hot loop.
inner_system_prompt = "Generate a useful dashboard UI."
# A13: forward inbound x-* headers via extra_headers as a defense in depth
# alongside the global httpx hook (see _header_forwarding.py). The hook
# patches httpx at module load, but extra_headers makes the intent
# explicit at the call site and is robust to alternative HTTP transports.
forwarded = get_forwarded_headers()
try:
response = await _async_openai_client.chat.completions.create(
model="gpt-4.1",
messages=[
{
"role": "system",
"content": inner_system_prompt,
},
{"role": "user", "content": user_prompt},
],
tools=[
ChatCompletionFunctionToolParam(
type="function",
# RENDER_A2UI_TOOL_SCHEMA is an untyped dict literal that
# conforms to the OpenAI FunctionDefinition TypedDict shape;
# cast so the type checker accepts it (no runtime change).
function=cast(FunctionDefinition, RENDER_A2UI_TOOL_SCHEMA),
)
],
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
extra_headers=forwarded or None,
)
except Exception as exc:
logger.error(
"generate_a2ui: inner LLM call failed type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps({"error": f"inner LLM call failed: {type(exc).__name__}"})
if not response.choices:
logger.warning("generate_a2ui: LLM returned no choices")
return json.dumps({"error": "LLM returned no choices"})
choice = response.choices[0]
if not choice.message.tool_calls:
logger.warning("generate_a2ui: secondary LLM produced no render_a2ui tool call")
return json.dumps({"error": "LLM did not call render_a2ui"})
# tool_calls is a union of function- and custom-tool calls; only the
# function variant carries `.function`. `tool_choice` above forces the
# `render_a2ui` FUNCTION tool, so the first call is always the function
# variant at runtime — narrow on `.type` to make that explicit to the type
# checker (and degrade gracefully to the same error shape if it ever isn't).
first_call = choice.message.tool_calls[0]
if first_call.type != "function":
logger.warning(
"generate_a2ui: secondary LLM returned non-function tool call type=%s",
first_call.type,
)
return json.dumps({"error": "LLM did not call render_a2ui"})
try:
args = json.loads(first_call.function.arguments)
result = build_a2ui_operations_from_tool_call(args)
return json.dumps(result)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
logger.error(
"generate_a2ui: failed to parse render_a2ui args type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps(
{"error": f"failed to parse render_a2ui args: {type(exc).__name__}"}
)
agent = ConversableAgent(
name="declarative_gen_ui_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=8,
functions=[generate_a2ui],
)
stream = AGUIStream(agent)
a2ui_dynamic_app = FastAPI()
a2ui_dynamic_app.mount("", stream.build_asgi())
@@ -0,0 +1,112 @@
"""AG2 agent for the Declarative Generative UI (A2UI Fixed Schema) demo.
Fixed-schema A2UI: the component tree (schema) is authored ahead of time
as JSON and shipped with the backend. The agent only streams *data* into
the data model at runtime via the `display_flight` tool. The frontend
registers a matching catalog (see
`src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts`).
Mirrors the langgraph-python `a2ui_fixed.py` reference. The dedicated
runtime route at `api/copilotkit-a2ui-fixed-schema/route.ts` runs the
A2UI middleware with `injectA2UITool: false` because the backend owns
the rendering tool itself.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
CATALOG_ID = "copilotkit://flight-fixed-catalog"
SURFACE_ID = "flight-fixed-schema"
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
def _load_schema(filename: str) -> list[dict]:
"""Load an A2UI fixed schema from the local schemas directory."""
with open(_SCHEMAS_DIR / filename, "r", encoding="utf-8") as fh:
return json.load(fh)
FLIGHT_SCHEMA = _load_schema("flight_schema.json")
async def display_flight(
origin: Annotated[str, "Origin airport code, e.g. 'SFO'"],
destination: Annotated[str, "Destination airport code, e.g. 'JFK'"],
airline: Annotated[str, "Airline name, e.g. 'United'"],
price: Annotated[str, "Price string, e.g. '$289'"],
) -> str:
"""Show a flight card for the given trip.
Emits an `a2ui_operations` container the runtime A2UI middleware
detects in tool results and forwards to the frontend renderer. The
frontend catalog resolves component names against the local React
components.
"""
# A2UI v0.9 NESTED operation format (createSurface/updateComponents/
# updateDataModel keys) — the runtime A2UI middleware's
# getOperationSurfaceId and the frontend renderer only understand this
# shape (mirrors copilotkit.a2ui helpers in sdk-python/copilotkit/a2ui.py).
# The previous flat {"type": "create_surface", ...} form parsed as a
# container but produced ops the renderer could not apply, so the
# a2ui-fixed-card never mounted.
operations = [
{
"version": "v0.9",
"createSurface": {
"surfaceId": SURFACE_ID,
"catalogId": CATALOG_ID,
},
},
{
"version": "v0.9",
"updateComponents": {
"surfaceId": SURFACE_ID,
"components": FLIGHT_SCHEMA,
},
},
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": SURFACE_ID,
"path": "/",
"value": {
"origin": origin,
"destination": destination,
"airline": airline,
"price": price,
},
},
},
]
return json.dumps({"a2ui_operations": operations})
SYSTEM_PROMPT = (
"You help users find flights. When asked about a flight, call "
"display_flight with origin (3-letter code), destination (3-letter "
"code), airline, and price (e.g. '$289'). Keep any chat reply to one "
"short sentence."
)
agent = ConversableAgent(
name="a2ui_fixed_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=4,
functions=[display_flight],
)
stream = AGUIStream(agent)
a2ui_fixed_app = FastAPI()
a2ui_fixed_app.mount("", stream.build_asgi())
@@ -0,0 +1,20 @@
[
{
"id": "root",
"component": "Column",
"gap": 8,
"children": ["title", "detail"]
},
{
"id": "title",
"component": "Text",
"text": { "path": "/title" },
"variant": "h2"
},
{
"id": "detail",
"component": "Text",
"text": { "path": "/detail" },
"variant": "body"
}
]
@@ -0,0 +1,77 @@
[
{
"id": "root",
"component": "Card",
"child": "content"
},
{
"id": "content",
"component": "Column",
"children": ["title", "route", "meta", "bookButton"]
},
{
"id": "title",
"component": "Title",
"text": "Flight Details"
},
{
"id": "route",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["from", "arrow", "to"]
},
{
"id": "from",
"component": "Airport",
"code": { "path": "/origin" }
},
{
"id": "arrow",
"component": "Arrow"
},
{
"id": "to",
"component": "Airport",
"code": { "path": "/destination" }
},
{
"id": "meta",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["airline", "price"]
},
{
"id": "airline",
"component": "AirlineBadge",
"name": { "path": "/airline" }
},
{
"id": "price",
"component": "PriceTag",
"amount": { "path": "/price" }
},
{
"id": "bookButton",
"component": "Button",
"variant": "primary",
"child": "bookButtonLabel",
"action": {
"event": {
"name": "book_flight",
"context": {
"origin": { "path": "/origin" },
"destination": { "path": "/destination" },
"airline": { "path": "/airline" },
"price": { "path": "/price" }
}
}
}
},
{
"id": "bookButtonLabel",
"component": "Text",
"text": "Book flight"
}
]
@@ -0,0 +1,257 @@
"""
AG2 agent with weather and sales tools for CopilotKit showcase.
Uses AG2's ConversableAgent with AGUIStream to expose
the agent via the AG-UI protocol.
"""
# @region[weather-tool-backend]
from __future__ import annotations
import json
import logging
from typing import Annotated, Any
import openai
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from dotenv import load_dotenv
from pydantic import ValidationError
load_dotenv()
# Import shared tool implementations
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
from ._header_forwarding import get_forwarded_headers
from ._request_context import get_latest_user_message
logger = logging.getLogger(__name__)
# Module-level async client: re-used across requests (httpx connection pool is
# thread-safe). Using AsyncOpenAI inside an `async def` avoids blocking the
# ASGI event loop on the secondary LLM call.
_async_openai_client = openai.AsyncOpenAI()
# =====
# Tools
# =====
async def get_weather(
location: Annotated[str, "City name to get weather for"],
) -> str:
"""Get current weather for a location."""
result = get_weather_impl(location)
# Return a JSON string (not a dict): autogen serializes dict returns with
# str(), producing a Python repr (single quotes) that the frontend's
# parseJsonResult/JSON.parse cannot parse — the weather card then renders
# "--" placeholders. Same pattern as search_flights below.
return json.dumps(
{
"city": result["city"],
"temperature": result["temperature"],
"feels_like": result["feels_like"],
"humidity": result["humidity"],
"wind_speed": result["wind_speed"],
"conditions": result["conditions"],
}
)
# @endregion[weather-tool-backend]
async def query_data(
query: Annotated[str, "Natural language query for financial data"],
) -> str:
"""Query financial database for chart data."""
# Return a JSON string (not a list): autogen serializes non-str returns
# with str(), producing a Python repr (single quotes) that the frontend's
# parseJsonResult/JSON.parse cannot parse. Same pattern as get_weather.
return json.dumps(query_data_impl(query))
async def manage_sales_todos(
todos: Annotated[list, "Complete list of sales todos"],
) -> str:
"""Manage the sales pipeline."""
# See contract comment on query_data above — return JSON, not dict.
# SalesTodo is a Pydantic model; coerce via model_dump for serialisability.
result = [t.model_dump() for t in manage_sales_todos_impl(todos)]
return json.dumps({"todos": result})
async def get_sales_todos() -> str:
"""Get the current sales pipeline."""
# See contract comment on query_data above — return JSON, not list.
# SalesTodo is a Pydantic model; coerce via model_dump for serialisability.
return json.dumps([t.model_dump() for t in get_sales_todos_impl(None)])
async def schedule_meeting(
reason: Annotated[str, "Reason for the meeting"],
) -> str:
"""Schedule a meeting with user approval."""
# See contract comment on query_data above — return JSON, not dict.
return json.dumps(schedule_meeting_impl(reason))
async def search_flights(
flights: Annotated[
list[dict[str, Any]], "List of flight objects to display as rich A2UI cards"
],
) -> str:
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
Each flight must have: airline, airlineLogo, flightNumber, origin, destination,
date (short readable format like "Tue, Mar 18" -- use near-future dates),
departureTime, arrivalTime, duration (e.g. "4h 25m"),
status (e.g. "On Time" or "Delayed"),
statusColor (hex color for status dot),
price (e.g. "$289"), and currency (e.g. "USD").
For airlineLogo use Google favicon API:
https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
"""
try:
typed_flights: list[Flight] = [Flight(**f) for f in flights]
except ValidationError as exc:
logger.warning(
"search_flights: invalid flight shape type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps({"error": f"invalid flight shape: {exc}"})
result = search_flights_impl(typed_flights)
return json.dumps(result)
async def generate_a2ui(
context: Annotated[str, "Conversation context to generate UI for"],
) -> str:
"""Generate dynamic A2UI components based on the conversation.
A secondary LLM designs the UI schema and data. The result is
returned as an a2ui_operations container for the middleware to detect.
"""
# A13: AsyncOpenAI inside async def (was sync openai.OpenAI which blocks
# the ASGI event loop). Forward x-* headers via extra_headers in addition
# to the global httpx hook so aimock context routing is explicit at the
# call site.
#
# R2-A1 / A4: thread the latest user prompt from the inbound
# RunAgentInput.messages payload (captured into a per-request ContextVar
# by RequestUserMessageMiddleware — see agents/_request_context.py) into
# the inner LLM call so each pill's request body is byte-distinct.
# Without this, every pill landing on the omnibus agent (agentic-chat /
# tool-rendering / chat-customization-css / hitl) produces an IDENTICAL
# inner-LLM body and the aimock fixture cannot disambiguate. Falls back
# to the original hardcoded prompt when the middleware captured nothing
# (parse failure already logged at WARNING).
user_prompt = get_latest_user_message() or (
"Generate a dynamic A2UI dashboard based on the conversation."
)
forwarded = get_forwarded_headers()
try:
response = await _async_openai_client.chat.completions.create(
model="gpt-4.1",
messages=[
{
"role": "system",
"content": context or "Generate a useful dashboard UI.",
},
{
"role": "user",
"content": user_prompt,
},
],
tools=[
{
"type": "function",
"function": RENDER_A2UI_TOOL_SCHEMA,
}
],
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
extra_headers=forwarded or None,
)
except Exception as exc:
logger.error(
"generate_a2ui: inner LLM call failed type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps({"error": f"inner LLM call failed: {type(exc).__name__}"})
if not response.choices:
logger.warning("generate_a2ui: LLM returned no choices")
return json.dumps({"error": "LLM returned no choices"})
choice = response.choices[0]
if not choice.message.tool_calls:
logger.warning("generate_a2ui: secondary LLM produced no render_a2ui tool call")
return json.dumps({"error": "LLM did not call render_a2ui"})
try:
args = json.loads(choice.message.tool_calls[0].function.arguments)
result = build_a2ui_operations_from_tool_call(args)
return json.dumps(result)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
logger.error(
"generate_a2ui: failed to parse render_a2ui args type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps(
{"error": f"failed to parse render_a2ui args: {type(exc).__name__}"}
)
# =====
# Agent
# =====
agent = ConversableAgent(
name="assistant",
system_message=(
"You are a helpful sales assistant. You can look up current weather "
"for any city using the get_weather tool, query financial data with "
"query_data, manage the sales pipeline with manage_sales_todos and "
"get_sales_todos, schedule meetings with schedule_meeting, search "
"flights and display rich A2UI cards with search_flights, and "
"generate dynamic A2UI dashboards with generate_a2ui. "
"When asked about the weather, always use the tool rather than guessing. "
"Be concise and friendly in your responses."
),
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
# Guard against infinite tool-call loops: AG2's ConversableAgent with
# human_input_mode="NEVER" will keep executing tool calls indefinitely
# if the LLM keeps requesting them. Without this limit the agent floods
# Railway's log stream (500 logs/sec rate-limit), becomes unresponsive
# to health probes, and gets killed by the watchdog.
max_consecutive_auto_reply=15,
functions=[
get_weather,
query_data,
manage_sales_todos,
get_sales_todos,
schedule_meeting,
search_flights,
generate_a2ui,
],
)
# AG-UI stream wrapper
stream = AGUIStream(agent)
@@ -0,0 +1,111 @@
"""AG2 agent backing the Agent Config Object demo.
Reads three forwarded properties — tone, expertise, responseLength — from
shared state (ContextVariables on each run) and adapts its responses
accordingly.
Wire format
-----------
The frontend uses `agent.setState({ tone, expertise, responseLength })` from
the demo page. AG2's AGUIStream maps that initial state into ContextVariables
on every run. The agent has a `get_current_config` tool that returns the
current rulebook for the assistant to consult before answering.
The system prompt instructs the agent to call `get_current_config` once at
the start of every conversation turn so the response style adapts to the
latest UI selection.
References:
- src/agents/shared_state_read_write.py — same ContextVariables pattern.
"""
import logging
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables
from autogen.tools import tool
from fastapi import FastAPI
logger = logging.getLogger(__name__)
VALID_TONES = {"professional", "casual", "enthusiastic"}
VALID_EXPERTISE = {"beginner", "intermediate", "expert"}
VALID_RESPONSE_LENGTHS = {"concise", "detailed"}
DEFAULT_TONE = "professional"
DEFAULT_EXPERTISE = "intermediate"
DEFAULT_RESPONSE_LENGTH = "concise"
TONE_RULES = {
"professional": "Use neutral, precise language. No emoji. Short sentences.",
"casual": (
"Use friendly, conversational language. Contractions OK. Light humor welcome."
),
"enthusiastic": (
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
),
}
EXPERTISE_RULES = {
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
"intermediate": ("Assume common terms are understood; explain specialized terms."),
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
}
LENGTH_RULES = {
"concise": "Respond in 1-3 sentences.",
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
}
SYSTEM_PROMPT = (
"You are a helpful assistant whose response style is governed by a UI-"
"supplied configuration object. Before answering ANY user question, "
"call the `get_current_config` tool exactly once to read the latest "
"tone / expertise / response-length rulebook. Then answer the user's "
"question, strictly following those rules. Never mention the tool call "
"or the configuration in your reply — just adapt your style."
)
@tool()
def get_current_config(context_variables: ContextVariables) -> str:
"""Return the current rulebook (tone / expertise / length) for the assistant.
Reads the forwarded ``tone``, ``expertise``, and ``responseLength``
properties from shared state, falling back to defaults for any missing
or unrecognized value.
"""
data = context_variables.data or {}
tone = data.get("tone", DEFAULT_TONE)
expertise = data.get("expertise", DEFAULT_EXPERTISE)
response_length = data.get("responseLength", DEFAULT_RESPONSE_LENGTH)
if tone not in VALID_TONES:
tone = DEFAULT_TONE
if expertise not in VALID_EXPERTISE:
expertise = DEFAULT_EXPERTISE
if response_length not in VALID_RESPONSE_LENGTHS:
response_length = DEFAULT_RESPONSE_LENGTH
return (
f"Tone ({tone}): {TONE_RULES[tone]}\n"
f"Expertise ({expertise}): {EXPERTISE_RULES[expertise]}\n"
f"Response length ({response_length}): {LENGTH_RULES[response_length]}"
)
agent_config_agent = ConversableAgent(
name="agent_config_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=5,
functions=[get_current_config],
)
agent_config_stream = AGUIStream(agent_config_agent)
agent_config_app = FastAPI()
agent_config_app.mount("/", agent_config_stream.build_asgi())
@@ -0,0 +1,142 @@
"""AG2 agent for the simplified Beautiful Chat demo.
This is a SIMPLIFIED port of the langgraph-python `beautiful_chat` graph.
The canonical version simultaneously exercises three big features:
1. A2UI Dynamic Schema (a `generate_a2ui` tool whose secondary LLM emits
schema-validated component compositions).
2. Open Generative UI (the runtime auto-registers `generateSandboxedUi`
on the frontend; the agent calls it for richer free-form widgets).
3. MCP Apps (an mcpApps server is mounted on the runtime; its tools and
UI resources are surfaced to the agent).
For AG2 we ship the FIRST TWO surfaces in a single cell: A2UI dynamic
generation for branded, component-bound visuals (KPIs, dashboards, status
reports, simple charts) AND Open Generative UI for free-form / educational
visualisations the catalog cannot express. We deliberately leave MCP out
to keep the AG2 port focused — `/demos/mcp-apps` already covers MCP on
its own.
The agent owns `generate_a2ui` explicitly (mirroring `a2ui_dynamic.py`).
The runtime route at `src/app/api/copilotkit-beautiful-chat/route.ts`
sets `a2ui.injectA2UITool: false` so the runtime doesn't double-bind a
second A2UI tool, and turns on `openGenerativeUI` for this agent so the
runtime injects `generateSandboxedUi` on the frontend.
"""
from __future__ import annotations
import json
import os
from typing import Annotated
import openai
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
from tools import (
build_a2ui_operations_from_tool_call,
RENDER_A2UI_TOOL_SCHEMA,
)
SYSTEM_PROMPT = """You are the Beautiful Chat assistant — a CopilotKit
showcase agent that answers user questions with rich, branded visuals.
You have TWO complementary visual surfaces. Pick whichever fits the
request best, but ALWAYS render something visual rather than replying
with plain text when the question warrants it.
1. `generate_a2ui` — for STRUCTURED, branded visuals composed from a
registered React catalog. Use it for:
- KPI dashboards (Metric + Card + Row/Column layouts)
- Status reports (StatusBadge / Card)
- Pie charts of part-of-whole breakdowns (PieChart)
- Bar charts comparing categories (BarChart)
- Info panels and quick summaries
Pass a single `context` argument summarising the conversation; the
secondary LLM will design the composition against the registered
catalog (Card, StatusBadge, Metric, InfoRow, PrimaryButton,
PieChart, BarChart, plus the basic A2UI primitives).
2. `generateSandboxedUi` — auto-registered by the frontend when Open
Generative UI is enabled. Use it for FREE-FORM visualisations the
catalog cannot express:
- Educational visualisations (algorithm walkthroughs, neural-net
activations, geometric proofs, physics simulations)
- Custom illustrations / diagrams
- Anything intricate that needs inline SVG, CSS animation, or an
interactive sandboxed widget
Output `initialHeight` (typically 480-560), a short
`placeholderMessages` array, complete `css`, then `html` with inline
SVG. No fetch / XHR / localStorage.
Decision rule of thumb: if the request maps to a chart, dashboard,
status report, or KPI summary, prefer `generate_a2ui`. If it asks for a
diagram, animation, or anything outside the catalog's components,
prefer `generateSandboxedUi`. Either way, keep the chat reply to one
short sentence — let the visual do the talking.
"""
async def generate_a2ui(
context: Annotated[
str, "Conversation context summary the secondary LLM should design UI from"
],
) -> str:
"""Generate dynamic A2UI components based on the conversation.
Mirrors `a2ui_dynamic.py`: a secondary LLM is bound to the
`render_a2ui` tool with `tool_choice` forced, and the resulting
arguments are wrapped into an `a2ui_operations` container the
runtime A2UI middleware detects and forwards to the frontend.
"""
client = openai.OpenAI()
response = client.chat.completions.create(
model="gpt-4.1",
messages=[
{
"role": "system",
"content": context or "Generate a useful dashboard UI.",
},
{
"role": "user",
"content": "Generate a dynamic A2UI dashboard based on the conversation.",
},
],
tools=[
{
"type": "function",
"function": RENDER_A2UI_TOOL_SCHEMA,
}
],
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
)
choice = response.choices[0]
if choice.message.tool_calls:
args = json.loads(choice.message.tool_calls[0].function.arguments)
result = build_a2ui_operations_from_tool_call(args)
return json.dumps(result)
return json.dumps({"error": "LLM did not call render_a2ui"})
agent = ConversableAgent(
name="beautiful_chat_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
human_input_mode="NEVER",
# The agent may call generate_a2ui (its own backend tool) and
# generateSandboxedUi (frontend tool injected by the OGUI runtime
# middleware). Cap the loop to keep tool storms bounded.
max_consecutive_auto_reply=8,
functions=[generate_a2ui],
)
stream = AGUIStream(agent)
beautiful_chat_app = FastAPI()
beautiful_chat_app.mount("", stream.build_asgi())
@@ -0,0 +1,102 @@
"""AG2 agent backing the byoc-hashbrown demo.
Emits hashbrown-shaped structured output that the ported HashBrownDashboard
renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) progressively
parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
Wire format
-----------
A single JSON object literal of the form:
{
"ui": [
{ "metric": { "props": { "label": "...", "value": "..." } } },
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } },
{ "Markdown": { "props": { "children": "## heading\\nbody" } } }
]
}
Every node is a single-key object `{tagName: {props: {...}}}`. `pieChart` and
`barChart` receive `data` as a JSON-encoded string (kept stable under partial
streaming).
"""
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
You are a sales analytics assistant that replies by emitting a single JSON
object consumed by a streaming JSON parser on the frontend.
ALWAYS respond with a single JSON object of the form:
{
"ui": [
{ <componentName>: { "props": { ... } } },
...
]
}
Do NOT wrap the response in code fences. Do NOT include any preface or
explanation outside the JSON object. The response MUST be valid JSON.
Available components and their prop schemas:
- "metric": { "props": { "label": string, "value": string } }
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
- "pieChart": { "props": { "title": string, "data": string } }
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
array of {label, value} objects with at least 3 segments, e.g.
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".
- "barChart": { "props": { "title": string, "data": string } }
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
{label, value} objects with at least 3 bars, typically time-ordered.
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
raw number (no currency symbol or comma).
- "Markdown": { "props": { "children": string } }
Short explanatory text. Use for section headings and brief summaries.
Standard markdown is supported in `children`.
Rules:
- Always produce plausible sample data when the user asks for a dashboard or
chart — do not refuse for lack of data.
- Prefer 3-6 rows of data in charts; keep labels short.
- Use "Markdown" for short headings or linking sentences between visual
components. Do not emit long prose.
- Do not emit components that are not listed above.
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
Example response (sales dashboard):
{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}
"""
byoc_hashbrown_agent = ConversableAgent(
name="byoc_hashbrown_assistant",
system_message=BYOC_HASHBROWN_SYSTEM_PROMPT,
llm_config=LLMConfig(
{
"model": "gpt-4o-mini",
"stream": True,
"response_format": {"type": "json_object"},
}
),
human_input_mode="NEVER",
max_consecutive_auto_reply=3,
functions=[],
)
byoc_hashbrown_stream = AGUIStream(byoc_hashbrown_agent)
byoc_hashbrown_app = FastAPI()
byoc_hashbrown_app.mount("/", byoc_hashbrown_stream.build_asgi())
@@ -0,0 +1,118 @@
"""AG2 agent backing the BYOC json-render demo.
Emits a single JSON object shaped like `@json-render/react`'s flat spec
format (`{ root, elements }`) so the frontend can feed it directly into
`<Renderer />` against a Zod-validated catalog of three components —
MetricCard, BarChart, PieChart.
"""
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """
You are a sales-dashboard UI generator for a BYOC json-render demo.
When the user asks for a UI, respond with **exactly one JSON object** and
nothing else — no prose, no markdown fences, no leading explanation. The
object must match this schema (the "flat element map" format consumed by
`@json-render/react`):
{
"root": "<id of the root element>",
"elements": {
"<id>": {
"type": "<component name>",
"props": { ... component-specific props ... },
"children": [ "<id>", ... ]
},
...
}
}
Available components (use each name verbatim as "type"):
- MetricCard
props: { "label": string, "value": string, "trend": string | null }
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
- BarChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
- PieChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
Rules:
1. Output **only** valid JSON. No markdown code fences. No text outside the object.
2. Every id referenced in `root` or any `children` array must be a key in `elements`.
3. For a multi-component dashboard, use a root MetricCard and list the charts in
its `children` array, OR pick any element as root and list the others as its
children. Do not emit orphan elements.
4. Use realistic sales-domain values (revenue, pipeline, conversion, categories,
months) — the demo is a sales dashboard.
5. `children` is optional but when present must be an array of strings.
6. Never invent component types outside the three listed above.
### Worked example — "Show me the sales dashboard with metrics and a revenue chart"
{
"root": "revenue-metric",
"elements": {
"revenue-metric": {
"type": "MetricCard",
"props": {
"label": "Revenue (Q3)",
"value": "$1.24M",
"trend": "+18% vs Q2"
},
"children": ["revenue-bar"]
},
"revenue-bar": {
"type": "BarChart",
"props": {
"title": "Monthly revenue",
"description": "Revenue by month across Q3",
"data": [
{ "label": "Jul", "value": 380000 },
{ "label": "Aug", "value": 410000 },
{ "label": "Sep", "value": 450000 }
]
}
}
}
}
Respond with the JSON object only.
"""
byoc_json_render_agent = ConversableAgent(
name="byoc_json_render_assistant",
system_message=SYSTEM_PROMPT.strip(),
llm_config=LLMConfig(
{
"model": "gpt-4o-mini",
"stream": True,
"temperature": 0.2,
"response_format": {"type": "json_object"},
}
),
human_input_mode="NEVER",
max_consecutive_auto_reply=3,
functions=[],
)
byoc_json_render_stream = AGUIStream(byoc_json_render_agent)
byoc_json_render_app = FastAPI()
byoc_json_render_app.mount("/", byoc_json_render_stream.build_asgi())
@@ -0,0 +1,126 @@
"""gen-ui-agent — minimal AG2 agent with explicit `steps` state schema.
Mirrors `langgraph-python/src/agents/gen_ui_agent.py` and
`ms-agent-python/src/agents/gen_ui_agent.py`. The frontend
(`src/app/demos/gen-ui-agent/page.tsx`) subscribes to
`agent.state.steps` via `useAgent` and renders a live progress card; the
backend's job is to plan exactly 3 steps and walk each
pending -> in_progress -> completed by calling the `set_steps` tool.
Every call to `set_steps` returns a `ReplyResult` whose
`context_variables` carry the updated `steps` array, which AG2's
`AGUIStream` surfaces back to the UI as a state snapshot so the
progress card re-renders in-place after every transition.
State shape (mirrors LGP `GenUiAgentState.steps`):
[
{"id": "...", "title": "...", "status": "pending" | "in_progress" | "completed"},
...
]
AG2 specifics:
- Uses `ContextVariables` + `ReplyResult` (same mechanism as
`shared_state_read_write.py`) to publish state. AG2's AG-UI adapter
emits a STATE_SNAPSHOT event after every `ReplyResult` so the
frontend sees the full `steps` list on each `set_steps` call.
- Mounts a dedicated FastAPI sub-app so this demo gets its own
ContextVariables slot, isolated from the shared default agent.
"""
import logging
from textwrap import dedent
from typing import Annotated, List
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables, ReplyResult
from autogen.tools import tool
from fastapi import FastAPI
logger = logging.getLogger(__name__)
@tool()
async def set_steps(
context_variables: ContextVariables,
steps: Annotated[
List[dict],
(
"The complete source of truth for the plan: every step "
"with `id`, `title`, and `status` ('pending' | "
"'in_progress' | 'completed'). Always include the FULL "
"list on every call, never a diff."
),
],
) -> ReplyResult:
"""Publish the current plan and step statuses.
Call this every time a step transitions (including the first
enumeration of steps). Always include the full list of steps on
each call.
"""
# Normalize: keep only the fields the UI consumes, in case the LLM
# tacked on extras. Tolerant of missing fields so the agent doesn't
# hard-fail mid-run.
cleaned: list[dict] = []
for step in steps or []:
if not isinstance(step, dict):
continue
cleaned.append(
{
"id": str(step.get("id", "")),
"title": str(step.get("title", step.get("description", ""))),
"status": str(step.get("status", "pending")),
}
)
context_variables.update({"steps": cleaned})
return ReplyResult(
message=f"Published {len(cleaned)} step(s).",
context_variables=context_variables,
)
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. Step 1: call `set_steps` with step 1 at status="in_progress",
then call `set_steps` again with step 1 at status="completed".
3. Step 2: call `set_steps` with step 2 at status="in_progress",
then call `set_steps` again with step 2 at status="completed".
4. Step 3: call `set_steps` with step 3 at status="in_progress",
then call `set_steps` again with step 3 at status="completed".
5. Send ONE final conversational assistant message summarizing the
plan, then stop. Do not call any more tools after step 3 is
completed.
Rules:
- Never call set_steps in parallel — always wait for one call to
return before the next.
- Always pass the COMPLETE list of steps on every call (existing +
updated), never a diff.
- Each step needs `id` (stable string id like "step-1"), `title`
(short human-readable description), and `status`
('pending' | 'in_progress' | 'completed').
- After all three steps are completed you MUST send a final
assistant message and terminate.
"""
).strip()
agent = ConversableAgent(
name="gen_ui_agent",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
# Nominal cost is ~7 set_steps cycles + 1 final model turn.
# 15 gives ~2x headroom for retries inside the LLM loop while still
# bounding pathological runaway behavior (Railway log-rate limits).
max_consecutive_auto_reply=15,
functions=[set_steps],
)
stream = AGUIStream(agent)
gen_ui_agent_app = FastAPI()
gen_ui_agent_app.mount("", stream.build_asgi())
@@ -0,0 +1,88 @@
"""AG2 agent backing the Headless Chat (Complete) demo.
The cell exists to prove that every CopilotKit rendering surface works
when the chat UI is composed manually (no <CopilotChatMessageView /> or
<CopilotChatAssistantMessage />). To exercise those surfaces we give
this agent two mock backend tools (``get_weather``, ``get_stock_price``)
which the frontend renders via app-registered ``useRenderTool``
renderers, plus a frontend-registered ``useComponent`` tool
(``highlight_note``) that the agent can invoke -- the UI flows through
the same ``useRenderToolCall`` path.
The system prompt nudges the model toward the right surface per user
question and falls back to plain text otherwise.
"""
from __future__ import annotations
from typing import Annotated
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = (
"You are a helpful, concise assistant wired into a headless chat "
"surface that demonstrates CopilotKit's full rendering stack. Pick "
"the right surface for each user question and fall back to plain "
"text when none of the tools fit.\n\n"
"Routing rules:\n"
" - If the user asks about weather for a place, call `get_weather` "
"with the location.\n"
" - If the user asks about a stock or ticker (AAPL, TSLA, MSFT, "
"...), call `get_stock_price` with the ticker.\n"
" - If the user asks you to highlight, flag, or mark a short note "
"or phrase, call the frontend `highlight_note` tool with the text "
"and a color (yellow, pink, green, or blue). Do NOT ask the user "
"for the color -- pick a sensible one if they didn't say.\n"
" - Otherwise, reply in plain text.\n\n"
"After a tool returns, write one short sentence summarizing the "
"result. Never fabricate data a tool could provide."
)
async def get_weather(
location: Annotated[str, "City or place to look up the weather for"],
) -> dict:
"""Get the current weather for a given location.
Returns a mock payload with city, temperature in Fahrenheit, humidity,
wind speed, and conditions.
"""
return {
"city": location,
"temperature": 68,
"humidity": 55,
"wind_speed": 10,
"conditions": "Sunny",
}
async def get_stock_price(
ticker: Annotated[str, "Stock ticker symbol (e.g. AAPL, TSLA, MSFT)"],
) -> dict:
"""Get a mock current price for a stock ticker.
Returns a payload with the ticker symbol (uppercased), price in USD,
and percentage change for the day.
"""
return {
"ticker": ticker.upper(),
"price_usd": 189.42,
"change_pct": 1.27,
}
agent = ConversableAgent(
name="headless_complete_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=8,
functions=[get_weather, get_stock_price],
)
stream = AGUIStream(agent)
headless_complete_app = FastAPI()
headless_complete_app.mount("", stream.build_asgi())
@@ -0,0 +1,63 @@
"""
AG2 scheduling agent -- interrupt-adapted.
This agent powers two demos (gen-ui-interrupt, interrupt-headless) that in the
LangGraph showcase rely on the native `interrupt()` primitive with
checkpoint/resume. AG2 does NOT have that primitive, so we adapt using the
same "Strategy B" pattern as the MS Agent Framework port: the backend agent's
system prompt tells the LLM to call `schedule_meeting`, but no local
implementation is registered -- the tool is provided entirely by the frontend
via `useFrontendTool` with an async handler that returns a Promise resolving
only once the user picks a time slot (or cancels).
See `src/agents/agent.py` for the shared ConversableAgent used by most other
AG2 demos.
"""
# @region[backend-interrupt-tool]
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
# @region[backend-tool-call]
SYSTEM_PROMPT = (
"You are a scheduling assistant. Whenever the user asks you to book a call "
"or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a "
"short `topic` describing the purpose of the meeting and, if known, an "
"`attendee` describing who the meeting is with.\n\n"
"The `schedule_meeting` tool is implemented on the client: it surfaces a "
"time-picker UI to the user and returns the user's selection. After the "
"tool returns, briefly confirm whether the meeting was scheduled and at "
"what time, or note that the user cancelled. Do NOT ask for approval "
"yourself -- always call the tool and let the picker handle the decision.\n\n"
"Keep responses short and friendly. After you finish executing tools, "
"always send a brief final assistant message summarizing what happened so "
"the message persists."
)
interrupt_agent = ConversableAgent(
name="scheduling_agent",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=5,
# No backend tools. `schedule_meeting` is registered on the frontend
# via `useFrontendTool` and dispatched through the CopilotKit runtime.
# When the agent calls `schedule_meeting`, the request is routed to
# the frontend handler, which returns a Promise that only resolves
# once the user picks a slot -- equivalent to `interrupt()` in the
# LangGraph reference.
functions=[],
)
# @endregion[backend-tool-call]
# @endregion[backend-interrupt-tool]
# AG-UI stream wrapper
interrupt_stream = AGUIStream(interrupt_agent)
# FastAPI sub-app so agent_server.py can mount at /interrupt-adapted
interrupt_app = FastAPI(title="AG2 Interrupt Agent")
interrupt_app.mount("/", interrupt_stream.build_asgi())
@@ -0,0 +1,71 @@
"""AG2 agent for the CopilotKit MCP Apps demo.
This agent has no bespoke tools. The CopilotKit runtime (see
`src/app/api/copilotkit-mcp-apps/route.ts`) is wired with
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
server. The runtime auto-applies the MCP Apps middleware: it merges the
remote MCP server's tools into the agent's tool list at request time and
emits the activity events that CopilotKit's built-in
``MCPAppsActivityRenderer`` renders inline as a sandboxed iframe.
Mirrors the langgraph-python `mcp_apps_agent.py` — a no-tools agent that
relies entirely on the runtime to inject MCP-backed tools.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """\
You draw simple diagrams in Excalidraw via the MCP tool.
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
for polish. Target: one tool call, done in seconds.
When the user asks for a diagram:
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
an optional title text.
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
3. Connect with arrows. Endpoints can be element centers or simple
coordinates — you don't need edge anchors / fixedPoint bindings.
4. Include ONE `cameraUpdate` at the END of the elements array that
frames the whole diagram. Use an approved 4:3 size (600x450 or
800x600). No opening camera needed.
5. Reply with ONE short sentence describing what you drew.
Every element needs a unique string `id` (e.g. `"b1"`, `"a1"`,
`"title"`). Standard sizes: rectangles 160x70, ellipses/diamonds
120x80, 40-80px gap between shapes.
Do NOT:
- Call `read_me`. You already know the basic shape API.
- Make multiple `create_view` calls.
- Iterate or refine. Ship on the first shot.
- Add decorative colors / fills / zone backgrounds unless the user
explicitly asks for them.
- Add labels on arrows unless crucial.
If the user asks for something specific (colors, more elements,
particular layout), follow their lead — but still in ONE call.
"""
agent = ConversableAgent(
name="mcp_apps_assistant",
system_message=SYSTEM_PROMPT,
# gpt-4o-mini for speed, mirroring the langgraph reference.
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=6,
# No bespoke tools — MCP server tools are injected by the runtime
# middleware at request time.
functions=[],
)
stream = AGUIStream(agent)
mcp_apps_app = FastAPI()
mcp_apps_app.mount("", stream.build_asgi())
@@ -0,0 +1,68 @@
"""AG2 agent backing the Multimodal Attachments demo.
Vision-capable AG2 ConversableAgent (gpt-4o) that accepts image + PDF
attachments. Images are forwarded to the model natively; PDFs are flattened
to inline text via `pypdf` so the model can read them without needing
file-part support.
The frontend (src/app/demos/multimodal/page.tsx) sends attachments as
AG-UI message content parts. AG2's ConversableAgent passes them through to
the underlying OpenAI API so vision adapters work natively.
Content-shape normalization
---------------------------
AG2's ``ConversableAgent`` runs every user message through
``autogen.code_utils.content_str``, which only accepts content-part
types in ``{"text", "input_text", "image_url", "input_image",
"function", "tool_call", "tool_calls"}``. CopilotChat / the AG-UI
runtime emits image and document attachments as the modern
``{"type": "image" | "document", "source": {...}}`` shape (and the
frontend at ``src/app/demos/multimodal/legacy-converter-shim.tsx``
APPENDS a legacy ``{"type": "binary", ...}`` mirror alongside it for
LangChain-based integrations). Both of those shapes trip the
allowed-types gate with::
ValueError("Wrong content format: unknown type image within the
content")
…before the request reaches the vision model (observed live in the D6
``multimodal`` probe; see commit d8a0a25db for the original NSF
quarantine). ``NormalizingAGUIStream`` (defined in
``_multimodal_normalize.py``) intercepts the parsed ``RunAgentInput``
messages AFTER Pydantic validation and rewrites the AG-UI content parts
to OpenAI ``image_url`` format before they reach autogen.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from fastapi import FastAPI
from ._multimodal_normalize import NormalizingAGUIStream
SYSTEM_PROMPT = (
"You are a helpful assistant. The user may attach images or documents "
"(PDFs). When they do, analyze the attachment carefully and answer the "
"user's question. If no attachment is present, answer the text question "
"normally. Keep responses concise (1-3 sentences) unless asked to go deep."
)
multimodal_agent = ConversableAgent(
name="multimodal_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o", "stream": True, "temperature": 0.2}),
human_input_mode="NEVER",
max_consecutive_auto_reply=5,
functions=[],
)
# NormalizingAGUIStream wraps AGUIStream and normalises AG-UI
# image/document/binary content parts to OpenAI image_url format AFTER
# RunAgentInput Pydantic parsing, BEFORE AgentService processes them.
# See _multimodal_normalize.py for the full interception-point rationale.
multimodal_stream = NormalizingAGUIStream(multimodal_agent)
multimodal_app = FastAPI()
multimodal_app.mount("/", multimodal_stream.build_asgi())
@@ -0,0 +1,81 @@
"""AG2 agent for the Open-Ended Generative UI (Advanced) demo.
Extends the minimal Open Generative UI cell with sandbox-function
calling: the agent-authored, sandboxed UI invokes host-page functions
(see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`) via
`Websandbox.connection.remote.<name>(...)` from inside the iframe.
The frontend passes `openGenerativeUI={{ sandboxFunctions }}` to the
provider; the runtime middleware injects descriptors of those functions
into agent context. The LLM reads the descriptors and emits HTML/JS that
calls into them.
Mirrors the langgraph-python `open_gen_ui_advanced_agent.py` reference.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """You are a UI-generating assistant for the Open Generative UI (Advanced) demo.
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. The generated UI must be INTERACTIVE and must invoke the
available host-side sandbox functions described in your agent context
(delivered via `copilotkit.context`) in response to user interactions.
Sandbox-function calling contract (inside the generated iframe):
- Call a host function with:
await Websandbox.connection.remote.<functionName>(args)
The call returns a Promise; await it.
- Each handler returns a plain object. Read the return shape from the
function's description in your context and use the EXACT field names
it returns (e.g. if the description says the handler returns
`{ ok, value }`, read `res.value` — not `res.result`).
- Descriptions, names, and JSON-schema parameter shapes for every
available sandbox function are listed in your context. Read them
carefully and wire at least one interactive UI element to call one.
Sandbox iframe restrictions (CRITICAL):
- The iframe runs with `sandbox="allow-scripts"` ONLY. Forms are NOT
allowed. You MUST NOT use <form> elements or <button type="submit">.
Clicking a submit button inside a sandboxed form is blocked by the
browser BEFORE any onsubmit handler runs, so the sandbox-function call
never fires.
- Use plain <button type="button"> elements and wire them with
addEventListener('click', ...) or an inline click handler. Do the same
for "Enter" keypresses on inputs: attach a `keydown` listener that
checks `e.key === 'Enter'` and calls your handler directly — do NOT
wrap inputs in a <form>.
Generation guidance:
- Emit `initialHeight` and `placeholderMessages` first, then CSS, then
HTML, then `jsFunctions` / `jsExpressions` if helpful.
- Always include a visible result element (e.g. an output div) that you
UPDATE after the sandbox function resolves, so the user can *see* the
round-trip: "Button clicked -> remote call -> visible result".
- Use CDN scripts (Chart.js, D3, etc.) via <script> tags in the HTML head
when you need libraries.
- Do NOT use fetch/XHR, localStorage, or document.cookie — the sandbox has
no same-origin access. ONLY use `Websandbox.connection.remote.*` for
host-page interactions.
- Keep your own chat message brief (1 sentence max); the rendered UI is
the real output.
"""
agent = ConversableAgent(
name="open_gen_ui_advanced_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=4,
functions=[],
)
stream = AGUIStream(agent)
open_gen_ui_advanced_app = FastAPI()
open_gen_ui_advanced_app.mount("", stream.build_asgi())
@@ -0,0 +1,63 @@
"""AG2 agent for the Open-Ended Generative UI (minimal) demo.
The agent has no tools. The frontend-registered `generateSandboxedUi`
tool (auto-registered by `CopilotKitProvider` when the runtime has
`openGenerativeUI` enabled) is merged into the agent's tool list at
request time by the AG-UI integration. When the LLM calls
`generateSandboxedUi`, the runtime's `OpenGenerativeUIMiddleware`
converts the streaming tool call into `open-generative-ui` activity
events the built-in renderer mounts inside a sandboxed iframe.
Mirrors the langgraph-python `open_gen_ui_agent.py` reference.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """You are a UI-generating assistant for an Open Generative UI demo
focused on intricate, educational visualisations (3D axes / rotations,
neural-network activations, sorting-algorithm walkthroughs, Fourier
series, wave interference, planetary orbits, etc.).
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. Design a visually polished, self-contained HTML + CSS +
SVG widget that *teaches* the requested concept.
The frontend injects a detailed "design skill" as agent context
describing the palette, typography, labelling, and motion conventions
expected — follow it closely. Key invariants:
- Use inline SVG (or <canvas>) for geometric content, not stacks of <div>s.
- Every axis is labelled; every colour-coded series has a legend.
- Prefer CSS @keyframes / transitions over setInterval; loop cyclical
concepts with animation-iteration-count: infinite.
- Motion must teach — animate the actual step of the concept, not decoration.
- No fetch / XHR / localStorage — the sandbox has no same-origin access.
Output order:
- `initialHeight` (typically 480-560 for visualisations) first.
- A short `placeholderMessages` array (2-3 lines describing the build).
- `css` (complete).
- `html` (streams live — keep it tidy). CDN <script> tags for Chart.js /
D3 / etc. go inside the html.
Keep your own chat message brief (1 sentence) — the real output is the
rendered visualisation.
"""
agent = ConversableAgent(
name="open_gen_ui_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=4,
functions=[],
)
stream = AGUIStream(agent)
open_gen_ui_app = FastAPI()
open_gen_ui_app.mount("", stream.build_asgi())
@@ -0,0 +1,339 @@
"""AG2 reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
Backs two showcase cells (both share this one backend):
- reasoning-custom (custom amber ReasoningBlock slot)
- reasoning-default (CopilotKit's built-in reasoning card)
Mirrors `showcase/integrations/agno/src/agents/reasoning_agent.py` plus its
`/reasoning/agui` server mount in `agno/src/agent_server.py`, adapted to AG2.
Why a custom route instead of the stock AGUIStream
--------------------------------------------------
AG2's stock `AGUIStream` (autogen.ag_ui) streams the model's text as
TEXT_MESSAGE_CONTENT and emits NO REASONING_MESSAGE_* events. Worse,
autogen's `ConversableAgent` consumes only `delta.content` / `delta.tool_calls`
from the OpenAI chat-completions stream — it drops the `delta.reasoning_content`
side-channel entirely (the channel aimock fixtures populate via their
`reasoning` field, and that reasoning models emit in production). So the stock
adapter can never light up CopilotKit's reasoning slot.
This module builds a small custom `/reasoning` sub-app (mounted by
`agent_server.py`, mirroring agno's `_run_reasoning_agent`) that:
1. Calls the OpenAI-compatible chat-completions endpoint directly
(streaming) with the agent's system prompt plus the full prior
conversation history (so follow-up questions keep their context, parity
with the agno reference) — a single LLM call, so it stays
aimock-friendly (no multi-call CoT loop).
2. Buffers the FULL upstream response, accumulating BOTH
`delta.reasoning_content` (native reasoning channel, what aimock's
`reasoning` field feeds) AND `delta.content` (the answer); it does not
forward upstream deltas incrementally.
3. Falls back to parsing <reasoning>...</reasoning> tags out of the text
when no native reasoning channel is present (defensive parity with
agno's fallback path).
4. Emits each channel as a single CONTENT delta:
REASONING_MESSAGE_START/CONTENT/END for the buffered reasoning portion,
then TEXT_MESSAGE_START/CONTENT/END for the buffered answer.
The emitted channel is REASONING_MESSAGE_* (role "reasoning") — NOT THINKING_*,
which @ag-ui/client silently drops.
The global httpx hook installed in agent_server.py forwards the inbound
`x-aimock-context` header onto the outbound OpenAI call so aimock matches the
ag2-scoped fixture.
"""
from __future__ import annotations
import asyncio
import re
import sys
import traceback
import uuid
from typing import AsyncIterator
import openai
from ag_ui.core import (
BaseEvent,
EventType,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from ag_ui.encoder import EventEncoder
from fastapi import FastAPI
from starlette.endpoints import HTTPEndpoint
from starlette.requests import Request
from starlette.responses import StreamingResponse
SYSTEM_PROMPT = (
"You are a helpful assistant. For each user question, first think "
"step-by-step about the approach, then give a concise answer."
)
MODEL = "gpt-4o-mini"
_REASONING_PATTERN = re.compile(
r"<reasoning>(.*?)</reasoning>",
re.DOTALL | re.IGNORECASE,
)
def _coerce_content(content) -> str:
"""Coerce an AG-UI message's content into a plain string.
Handles the multimodal list shape (join the text parts) and the
None/non-string fallbacks — the same coercion the previous
single-turn `_extract_user_input` applied to the last user message.
"""
content = content or ""
if isinstance(content, str):
return content
if isinstance(content, list):
# Multimodal content: join the text parts. Coerce each part's text to
# a string — a None or non-str `text` (e.g. an image part) would make
# str.join raise TypeError, so fall back to "" for any non-str value.
def _part_text(part) -> str:
text = (
part.get("text", "")
if isinstance(part, dict)
else getattr(part, "text", "")
)
return text if isinstance(text, str) else ""
return "".join(_part_text(part) for part in content)
return str(content)
def _to_chat_messages(messages: list) -> list:
"""Map the AG-UI message list into chat-completions `messages`.
System prompt first, then every prior user/assistant turn (in order)
with its coerced text content. tool/system messages from the input are
skipped — only the conversation turns are threaded so follow-up
questions keep their context (parity with the agno reference, which
threads full history through Agno's Agent).
For a single user-message input this returns exactly
``[{system}, {user: <text>}]`` — byte-equal to the previous single-turn
behaviour, which the aimock D6 fixtures replay. When the input has no
user/assistant turns the result is ``[{system}, {user: ""}]`` (an empty
user turn), preserving the prior empty-input behaviour.
"""
chat: list = [{"role": "system", "content": SYSTEM_PROMPT}]
turns = [
{"role": role, "content": _coerce_content(getattr(msg, "content", ""))}
for msg in (messages or [])
for role in (getattr(msg, "role", None),)
if role in ("user", "assistant")
]
if turns:
chat.extend(turns)
else:
chat.append({"role": "user", "content": ""})
return chat
async def _run_reasoning_agent(
run_input: RunAgentInput,
) -> AsyncIterator[BaseEvent]:
"""Stream one reasoning run, synthesizing REASONING_MESSAGE_* events.
Mirrors agno's `_run_reasoning_agent`: buffer the full response, split
reasoning from answer, emit REASONING_MESSAGE_* then TEXT_MESSAGE_*.
"""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
# Track the in-flight message frame so a mid-stream failure can close it
# with the matching *_END before RUN_ERROR. @ag-ui/client's verifyEvents
# rejects a RUN_FINISHED while a text/tool frame is open, and the apply
# layer otherwise leaves a half-built message in client state.
reasoning_msg_id: str | None = None
text_msg_id: str | None = None
try:
chat_messages = _to_chat_messages(run_input.messages or [])
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
# Single streaming chat-completions call. The global httpx hook
# (installed in agent_server.py) forwards x-aimock-context so aimock
# matches the ag2-scoped fixture. OPENAI_BASE_URL points the client at
# aimock in local/D6 runs and at the real API in production.
client = openai.AsyncOpenAI()
response_stream = await client.chat.completions.create(
model=MODEL,
messages=chat_messages,
stream=True,
)
# Accumulate both channels. autogen drops reasoning_content, so we read
# the chat-completions stream directly to capture it.
full_text = ""
native_reasoning = ""
async for chunk in response_stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta is None:
continue
# Native reasoning channel — aimock `reasoning` field / reasoning
# models surface this as delta.reasoning_content.
reasoning_piece = getattr(delta, "reasoning_content", None)
if reasoning_piece:
native_reasoning += reasoning_piece
if delta.content:
full_text += delta.content
native_reasoning = native_reasoning.strip()
if native_reasoning:
# Native channel present — gold-standard parity path. The answer is
# the streamed text minus any stray <reasoning> tags.
reasoning_text = native_reasoning
answer_text = _REASONING_PATTERN.sub("", full_text).strip()
else:
# Fallback: parse <reasoning>...</reasoning> tags from the text
# (non-reasoning models / no-native-reasoning fixtures).
match = _REASONING_PATTERN.search(full_text)
if match:
reasoning_text = match.group(1).strip()
answer_text = (
full_text[: match.start()] + full_text[match.end() :]
).strip()
else:
reasoning_text = ""
answer_text = full_text.strip()
# The stream completed successfully but yielded neither reasoning nor
# answer text — the run would otherwise emit RUN_STARTED→RUN_FINISHED
# with zero message frames and no diagnostics. Log one server-side warn
# so a silent-empty run is visible (no synthetic message frames).
if not reasoning_text and not answer_text:
print(
"[reasoning] WARN: stream completed but produced no reasoning"
" or answer text",
file=sys.stderr,
flush=True,
)
# Emit reasoning message if we have reasoning content.
if reasoning_text:
reasoning_msg_id = str(uuid.uuid4())
yield ReasoningMessageStartEvent(
type=EventType.REASONING_MESSAGE_START,
message_id=reasoning_msg_id,
role="reasoning",
)
yield ReasoningMessageContentEvent(
type=EventType.REASONING_MESSAGE_CONTENT,
message_id=reasoning_msg_id,
delta=reasoning_text,
)
yield ReasoningMessageEndEvent(
type=EventType.REASONING_MESSAGE_END,
message_id=reasoning_msg_id,
)
reasoning_msg_id = None
# Emit a text message (only when non-empty answer text exists) so
# CopilotKit renders an assistant bubble.
if answer_text:
text_msg_id = str(uuid.uuid4())
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=text_msg_id,
role="assistant",
)
yield TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=text_msg_id,
delta=answer_text,
)
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=text_msg_id,
)
text_msg_id = None
yield RunFinishedEvent(
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
)
except asyncio.CancelledError: # noqa: TRY302 — propagate cancellation
raise
except Exception as exc: # noqa: BLE001
# Log the full failure server-side (never sent to the browser).
print(f"[reasoning] run failed: {exc!r}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr)
# Close any message frame opened before the failure so the terminal
# RUN_ERROR is protocol-clean (no dangling *_START in client state).
if text_msg_id is not None:
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=text_msg_id,
)
if reasoning_msg_id is not None:
yield ReasoningMessageEndEvent(
type=EventType.REASONING_MESSAGE_END,
message_id=reasoning_msg_id,
)
# Generic client-facing message — no raw exception text (which can
# carry provider URLs / request details) reaches the SSE stream.
# RUN_ERROR is terminal: @ag-ui/client's verifyEvents rejects ANY
# event after it, so we do NOT emit RUN_FINISHED here.
yield RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"agent run failed: {type(exc).__name__} (see server logs)",
)
class ReasoningEndpoint(HTTPEndpoint):
"""Starlette HTTPEndpoint that emits REASONING_MESSAGE_* + TEXT_MESSAGE_*.
Mounted at the sub-app root (``reasoning_app.mount("/", ...)``) — the exact
same shape as AG2's stock ``AGUIStream.build_asgi()`` HTTPEndpoint that the
other ag2 sub-apps mount (see e.g. ``interrupt_agent.py``). agent_server
mounts this sub-app at ``/reasoning``; the HttpAgent posts to
``/reasoning/`` (route.ts ``createAgent("/reasoning/")``), so the outer
Mount strips ``/reasoning`` and the inner Mount at ``/`` resolves here.
"""
async def post(self, request: Request) -> StreamingResponse:
encoder = EventEncoder()
run_input = RunAgentInput.model_validate_json(await request.body())
async def _gen() -> AsyncIterator[str]:
async for event in _run_reasoning_agent(run_input):
yield encoder.encode(event)
return StreamingResponse(
_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
)
# FastAPI sub-app so agent_server.py can mount at /reasoning. Mounting the
# HTTPEndpoint at "/" mirrors the stock AGUIStream sub-apps
# (``app.mount("/", stream.build_asgi())``) — the HttpAgent posts to
# ``/reasoning/`` so the outer Mount strips ``/reasoning`` and this inner
# Mount at ``/`` resolves the endpoint.
reasoning_app = FastAPI(title="AG2 Reasoning Agent")
reasoning_app.mount("/", ReasoningEndpoint)
@@ -0,0 +1,163 @@
"""AG2 agent for the Shared State (Read + Write) demo.
Demonstrates the full bidirectional shared-state pattern between UI and
agent using AG2's ContextVariables + ReplyResult mechanism:
- **UI -> agent (write)**: The UI owns a `preferences` object (the user's
profile) that it writes into agent state via `agent.setState({...})`.
AG2's AGUIStream maps incoming initial state into ContextVariables on
every run. The agent calls `get_current_preferences` to read them, and
the system prompt tells it to do so before answering.
- **agent -> UI (read)**: The agent calls `set_notes` to update the
`notes` slot in shared state. Each call returns a ReplyResult that
attaches the updated ContextVariables, which AGUIStream surfaces back
to the UI so `useAgent({ updates: [OnStateChanged] })` re-renders.
Together this gives bidirectional shared state: frontend writes,
backend reads AND writes, frontend re-renders.
"""
import logging
from textwrap import dedent
from typing import List, Optional
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables, ReplyResult
from autogen.tools import tool
from fastapi import FastAPI
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class Preferences(BaseModel):
"""User preferences written by the UI into shared state."""
name: str = Field(default="", description="The user's preferred name")
tone: str = Field(
default="casual",
description="Preferred tone: 'formal', 'casual', or 'playful'",
)
language: str = Field(
default="English",
description="Preferred language (e.g. English, Spanish, ...)",
)
interests: List[str] = Field(
default_factory=list,
description="The user's interests (e.g. Cooking, Tech, Travel)",
)
class SharedSnapshot(BaseModel):
"""Full shape of the shared state slot.
Both the UI and the backend agree on this shape; it round-trips through
AG2's ContextVariables on every turn.
"""
preferences: Preferences = Field(default_factory=Preferences)
notes: List[str] = Field(default_factory=list)
def _load_snapshot(context_variables: ContextVariables) -> SharedSnapshot:
"""Best-effort load of the SharedSnapshot from context variables.
Falls back to an empty snapshot if state is missing or malformed —
this keeps the agent operational on the very first turn before the UI
has called ``agent.setState``.
"""
data = context_variables.data or {}
try:
return SharedSnapshot.model_validate(data)
except Exception as exc:
# Tolerant of partial state (e.g. only `preferences` set), but log
# WARNING so silent corruption is visible in server logs instead of
# quietly degrading to an empty snapshot.
logger.warning(
"shared_state_read_write: failed to validate SharedSnapshot "
"(%s: %s); attempting partial recovery from individual slots",
exc.__class__.__name__,
exc,
)
prefs_raw = data.get("preferences") or {}
notes_raw = data.get("notes") or []
try:
prefs = Preferences.model_validate(prefs_raw)
except Exception as prefs_exc:
logger.warning(
"shared_state_read_write: failed to validate Preferences "
"(%s: %s); falling back to defaults",
prefs_exc.__class__.__name__,
prefs_exc,
)
prefs = Preferences()
notes = [str(n) for n in notes_raw if isinstance(n, (str, int, float))]
return SharedSnapshot(preferences=prefs, notes=notes)
@tool()
async def get_current_preferences(context_variables: ContextVariables) -> str:
"""Return the user's preferences (name, tone, language, interests) as JSON.
Always call this BEFORE answering, so your reply respects the user's
preferred name, tone, language, and interests.
"""
snapshot = _load_snapshot(context_variables)
return snapshot.preferences.model_dump_json(indent=2)
@tool()
async def set_notes(
context_variables: ContextVariables,
notes: List[str],
) -> ReplyResult:
"""Replace the notes array in shared state with the FULL updated list.
Use this whenever the user asks you to "remember" something, or when you
have an observation worth surfacing in the UI's notes panel. Always
pass the FULL notes list (existing + new) — not a diff. Keep each note
short (< 120 chars).
"""
snapshot = _load_snapshot(context_variables)
cleaned = [str(n).strip() for n in notes if str(n).strip()]
snapshot.notes = cleaned
context_variables.update(snapshot.model_dump())
return ReplyResult(
message=f"Notes updated. Total notes: {len(cleaned)}.",
context_variables=context_variables,
)
agent = ConversableAgent(
name="shared_state_read_write_assistant",
system_message=dedent(
"""
You are a helpful, concise assistant.
Shared state contract:
- The UI writes the user's `preferences` (name, tone, language,
interests) into shared state. Call `get_current_preferences`
BEFORE answering, every turn, and tailor your reply to those
preferences. Address the user by name when appropriate.
- The UI displays a `notes` panel that mirrors a list you control.
When the user asks you to remember something, OR when you observe
something worth surfacing, call `set_notes` with the FULL updated
list of short note strings.
Rules:
- Never repeat preferences back at the user verbatim — just adapt.
- When calling `set_notes`, pass the COMPLETE list (existing +
new), never a diff.
- Keep messages short and respect the preferred tone.
"""
).strip(),
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=10,
functions=[get_current_preferences, set_notes],
)
stream = AGUIStream(agent)
shared_state_read_write_app = FastAPI()
shared_state_read_write_app.mount("", stream.build_asgi())
@@ -0,0 +1,316 @@
"""AG2 agent for the Sub-Agents demo.
Demonstrates multi-agent delegation with a visible delegation log.
A top-level "supervisor" ConversableAgent orchestrates three specialized
sub-agents — each itself a ConversableAgent — exposed as supervisor tools:
- `research_agent` — gathers facts
- `writing_agent` — drafts prose
- `critique_agent` — reviews drafts
Every delegation appends an entry to the `delegations` slot in shared
agent state (via AG2's ContextVariables + ReplyResult), so the UI can
render a live "delegation log" as the supervisor fans work out and
collects results. This is the canonical AG2 sub-agents-as-tools pattern,
adapted to surface delegation events to the frontend via AG-UI's
shared-state channel.
"""
# @region[supervisor-delegation-tools]
# @region[subagent-setup]
import asyncio
import logging
import uuid
from textwrap import dedent
from typing import List, Literal
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables, ReplyResult
from autogen.tools import tool
from fastapi import FastAPI
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
SubAgentName = Literal["research_agent", "writing_agent", "critique_agent"]
DelegationStatus = Literal["running", "completed", "failed"]
class Delegation(BaseModel):
"""One entry in the delegation log shown by the UI."""
id: str
sub_agent: SubAgentName
task: str
status: DelegationStatus = "completed"
result: str = ""
class SubagentsSnapshot(BaseModel):
"""Shape of the shared `delegations` state slot rendered by the UI."""
delegations: List[Delegation] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Sub-agents (real ConversableAgents under the hood)
# ---------------------------------------------------------------------------
#
# Each sub-agent is its own LLM ConversableAgent with a focused system
# prompt. They don't share memory or tools with the supervisor — the
# supervisor only sees what each sub-agent's final reply produces.
_SUB_LLM_CONFIG = LLMConfig({"model": "gpt-4o-mini", "stream": False})
_research_agent = ConversableAgent(
name="research_sub_agent",
system_message=dedent(
"""
You are a research sub-agent. Given a topic, produce a concise
bulleted list of 3-5 key facts. No preamble, no closing.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
_writing_agent = ConversableAgent(
name="writing_sub_agent",
system_message=dedent(
"""
You are a writing sub-agent. Given a brief and optional source
facts, produce a polished 1-paragraph draft. Be clear and
concrete. No preamble.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
_critique_agent = ConversableAgent(
name="critique_sub_agent",
system_message=dedent(
"""
You are an editorial critique sub-agent. Given a draft, produce
2-3 crisp, actionable critiques. No preamble.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
# @endregion[subagent-setup]
async def _invoke_sub_agent(sub_agent: ConversableAgent, task: str) -> str:
"""Run a sub-agent on `task` and return its final reply text.
`generate_reply` produces a single LLM completion against a one-shot
user message. AG2's ``generate_reply`` is synchronous and performs a
blocking LLM round-trip, so we offload it to a worker thread to keep
the asyncio event loop responsive while the call is in flight.
"""
reply = await asyncio.to_thread(
sub_agent.generate_reply,
messages=[{"role": "user", "content": task}],
)
if reply is None:
return ""
if isinstance(reply, dict):
# ConversableAgent.generate_reply may return {"content": "..."}.
return str(reply.get("content") or "")
return str(reply)
def _load_snapshot(context_variables: ContextVariables) -> SubagentsSnapshot:
"""Best-effort load of the SubagentsSnapshot from context variables.
Logs at WARNING when state fails validation so silent corruption is
visible in server logs instead of degrading to an empty snapshot
without a trace.
"""
data = context_variables.data or {}
try:
return SubagentsSnapshot.model_validate(data)
except Exception as exc:
logger.warning(
"subagents: failed to validate SubagentsSnapshot from context "
"variables (%s: %s); falling back to empty snapshot",
exc.__class__.__name__,
exc,
)
return SubagentsSnapshot()
def _record_delegation(
context_variables: ContextVariables,
sub_agent: SubAgentName,
task: str,
result: str,
status: DelegationStatus = "completed",
) -> ReplyResult:
"""Append a delegation entry to shared state and return ReplyResult."""
snapshot = _load_snapshot(context_variables)
snapshot.delegations.append(
Delegation(
id=str(uuid.uuid4()),
sub_agent=sub_agent,
task=task,
status=status,
result=result,
)
)
context_variables.update(snapshot.model_dump())
return ReplyResult(
message=result,
context_variables=context_variables,
)
async def _run_delegation(
context_variables: ContextVariables,
sub_agent_name: SubAgentName,
sub_agent: ConversableAgent,
task: str,
) -> ReplyResult:
"""Invoke a sub-agent and record the outcome (completed or failed).
If the underlying ``generate_reply`` raises (transport error, quota,
SDK bug, ...), we record the delegation with ``status='failed'`` and
return a sane ReplyResult so the supervisor can recover instead of
crashing the turn. The full traceback is logged server-side; the
user-facing ``result`` text only mentions the exception class to
avoid leaking internals.
"""
try:
result = await _invoke_sub_agent(sub_agent, task)
except Exception as exc:
logger.exception(
"subagents: sub-agent %s failed while handling task", sub_agent_name
)
failure_message = (
f"sub-agent call failed: {exc.__class__.__name__} (see server logs)"
)
return _record_delegation(
context_variables,
sub_agent_name,
task,
failure_message,
status="failed",
)
return _record_delegation(
context_variables,
sub_agent_name,
task,
result,
status="completed",
)
# ---------------------------------------------------------------------------
# Supervisor tools (each tool delegates to one sub-agent)
# ---------------------------------------------------------------------------
# Each @tool wraps a sub-agent invocation. The supervisor LLM "calls"
# these tools to delegate work; each call asynchronously runs the
# matching sub-agent, records the delegation into shared state via
# ContextVariables, and returns a ReplyResult the supervisor reads as
# its tool output on the next step.
@tool()
async def research_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a research task to the research sub-agent.
Use for: gathering facts, background, definitions, statistics. Returns
a bulleted list of key facts.
Args:
task: The specific research question or topic to investigate.
"""
return await _run_delegation(
context_variables, "research_agent", _research_agent, task
)
@tool()
async def writing_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a drafting task to the writing sub-agent.
Use for: producing a polished paragraph, draft, or summary. Pass
relevant facts from prior research inside ``task``.
Args:
task: The brief plus any relevant facts the writer should use.
"""
return await _run_delegation(
context_variables, "writing_agent", _writing_agent, task
)
@tool()
async def critique_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a critique task to the critique sub-agent.
Use for: reviewing a draft and suggesting concrete improvements.
Args:
task: The draft to critique (paste it directly into ``task``).
"""
return await _run_delegation(
context_variables, "critique_agent", _critique_agent, task
)
# @endregion[supervisor-delegation-tools]
# ---------------------------------------------------------------------------
# Supervisor (the agent we export)
# ---------------------------------------------------------------------------
supervisor = ConversableAgent(
name="supervisor",
system_message=dedent(
"""
You are a supervisor agent that coordinates three specialized
sub-agents to produce high-quality deliverables.
Available sub-agents (call them as tools):
- research_agent: gathers facts on a topic.
- writing_agent: turns facts + a brief into a polished draft.
- critique_agent: reviews a draft and suggests improvements.
For most non-trivial user requests, delegate in sequence:
research -> write -> critique. Pass the relevant facts/draft
through the `task` argument of each tool. Keep your own messages
short — explain the plan once, delegate, then return a concise
summary once done. The UI shows the user a live log of every
sub-agent delegation, so don't repeat sub-agent output verbatim
in your final reply — just summarize.
"""
).strip(),
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
# Limit supervisor steps to bound delegation fan-out.
max_consecutive_auto_reply=8,
functions=[research_agent, writing_agent, critique_agent],
)
stream = AGUIStream(supervisor)
subagents_app = FastAPI()
subagents_app.mount("", stream.build_asgi())
@@ -0,0 +1,111 @@
"""AG2 agent for the Tool Rendering (Reasoning Chain) demo.
A travel & lifestyle concierge that chains 2+ tool calls in succession
when relevant. The frontend wires renderers for `get_weather` and
`search_flights` plus a custom catch-all for the rest.
Note: AG2's ConversableAgent does not natively emit AG-UI
REASONING_MESSAGE_* events the way LangGraph's `deepagents` does, so the
reasoning slot may not show streaming "thinking…" text. The cell still
exercises the full tool-rendering chain and the custom reasoning slot
plumbing — the slot simply renders empty/skeletal until/if a reasoning
event arrives.
"""
from __future__ import annotations
import json
from random import choice, randint
from typing import Annotated
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
async def get_weather(
location: Annotated[str, "City or place to look up the weather for"],
) -> dict:
"""Get the current weather for a given location."""
return {
"city": location,
"temperature": 68,
"humidity": 55,
"wind_speed": 10,
"conditions": "Sunny",
}
async def search_flights(
origin: Annotated[str, "Origin airport code, e.g. 'SFO'"],
destination: Annotated[str, "Destination airport code, e.g. 'JFK'"],
) -> str:
"""Search mock flights from an origin airport to a destination."""
payload = {
"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:55",
"price_usd": 312,
},
{
"airline": "JetBlue",
"flight": "B6722",
"depart": "17:05",
"arrive": "01:30",
"price_usd": 289,
},
],
}
return json.dumps(payload)
async def get_stock_price(
ticker: Annotated[str, "Stock ticker symbol (e.g. AAPL, TSLA, MSFT)"],
) -> dict:
"""Get a mock current price for a stock ticker."""
return {
"ticker": ticker.upper(),
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
}
async def roll_dice(
sides: Annotated[int, "Number of sides on the die (default 6)"] = 6,
) -> dict:
"""Roll a single die with the given number of sides."""
return {"sides": sides, "result": randint(1, max(2, sides))}
SYSTEM_PROMPT = (
"You are a travel & lifestyle concierge. When a user asks a question, "
"reason step-by-step and call 2+ tools in succession when relevant. "
"For weather + travel questions, call get_weather then search_flights. "
"Keep the final summary to one short sentence."
)
agent = ConversableAgent(
name="tool_rendering_reasoning_chain_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=10,
functions=[get_weather, search_flights, get_stock_price, roll_dice],
)
stream = AGUIStream(agent)
tool_rendering_reasoning_chain_app = FastAPI()
tool_rendering_reasoning_chain_app.mount("", stream.build_asgi())
@@ -0,0 +1,52 @@
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its
// own endpoint lets us set `a2ui.injectA2UITool: false` — the backend AG2
// agent owns the `display_flight` tool which emits its own
// `a2ui_operations` container directly in the tool result.
//
// Reference:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
// - src/agents/a2ui_fixed.py (the AG2 backend)
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const a2uiFixedSchemaAgent = new HttpAgent({
url: `${AGENT_URL}/a2ui-fixed-schema/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
a2ui: {
// The backend agent emits its own `a2ui_operations` container inside
// `display_flight` (see src/agents/a2ui_fixed.py). We still run the A2UI
// middleware so it detects the container in tool results and forwards
// surfaces to the frontend — but we do NOT inject a runtime
// `render_a2ui` tool on top of the agent's existing tools.
injectA2UITool: false,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,204 @@
// Dedicated runtime for the Agent Config Object demo (AG2).
//
// The page at src/app/demos/agent-config/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="agent-config-demo"` (the slug registered
// below). The backing AG2 agent is a FastAPI sub-app mounted at
// `/agent-config` in src/agent_server.py, with its AGUIStream at the mount
// root — hence the trailing-slash URL, matching the sibling
// copilotkit-multimodal route's convention.
//
// Wire-contract bridge:
// The CopilotKit runtime forwards `CopilotKitCore.properties` as flat
// top-level keys on `forwardedProps`. To keep the wire contract identical
// across framework showcases (LangGraph / LlamaIndex / AG2 / etc.), we repack
// any non-structural forwardedProps key into
// `forwardedProps.config.configurable.properties` before forwarding the
// request to the Python backend. This mirrors the LlamaIndex showcase's
// agent-config route (see llamaindex/src/app/api/copilotkit-agent-config/
// route.ts) so a single TS-side wire contract serves all frameworks. (The
// AG2 demo page itself relays config via `useAgentContext` → shared state →
// ContextVariables, so the repack is a pass-through unless provider
// `properties` are supplied.)
//
// References:
// - src/agents/agent_config_agent.py — the AG2 agent + AGUIStream sub-app
// - src/app/demos/agent-config/page.tsx — the provider config
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
// Shape of the AG-UI run input we care about. We avoid a direct import of
// `RunAgentInput` from `@ag-ui/client` so this route has no additional
// peer-dep on internal AG-UI packages — the field we touch (`forwardedProps`)
// is part of the stable AG-UI protocol contract.
type RunInputWithForwardedProps = {
forwardedProps?: Record<string, unknown> | undefined;
[k: string]: unknown;
};
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Keys on `forwardedProps` that AG-UI treats as reserved stream-payload
// fields (e.g. `config`, `command`, `streamMode`). These must NOT be
// repacked under `configurable.properties` — they are structural fields.
// Anything else on `forwardedProps` is user-supplied frontend state that
// needs to reach the Python agent.
//
// Kept in sync with ag-ui/langgraph/typescript/src/agent.ts
// `RunAgentExtendedInput["forwardedProps"]`. AG2's stream uses a subset of
// these, but the superset is safe: structural keys present in the request
// body pass through to AG-UI's canonical shape regardless of which backend
// consumes them.
const RESERVED_FORWARDED_PROPS_KEYS = new Set<string>([
"config",
"command",
"streamMode",
"streamSubgraphs",
"nodeName",
"threadMetadata",
"checkpointId",
"checkpointDuring",
"interruptBefore",
"interruptAfter",
"multitaskStrategy",
"ifNotExists",
"afterSeconds",
"onCompletion",
"onDisconnect",
"webhook",
"feedbackKeys",
"metadata",
]);
/**
* Wrapper around `HttpAgent` that repacks the CopilotKit provider's
* `properties` (which arrive as top-level keys on `forwardedProps`) into
* `forwardedProps.config.configurable.properties`.
*
* Why this bridge exists: the CopilotKit runtime forwards
* `CopilotKitCore.properties` as `forwardedProps` (see core's run-handler).
* For wire-contract consistency with the LangGraph showcase, we stash them
* under `forwardedProps.config.configurable.properties` so a Python-side
* recomposer can read them from a single canonical location instead of
* sniffing top-level keys.
*/
class AgentConfigHttpAgent extends HttpAgent {
// Passthrough constructor so TS sees the same signature HttpAgent
// accepts ({ url }). Without this, subclassing narrows the inferred
// constructor to zero-arg when @ag-ui/client isn't fully resolvable in
// isolated typecheck passes.
constructor(...args: ConstructorParameters<typeof HttpAgent>) {
super(...args);
}
// Intercept each run() to repack provider `properties` (which land on
// `forwardedProps`) into `forwardedProps.config.configurable.properties`.
run(input: Parameters<HttpAgent["run"]>[0]): ReturnType<HttpAgent["run"]> {
const repacked = repackForwardedPropsIntoConfigurable(
input as unknown as RunInputWithForwardedProps,
);
return super.run(repacked as unknown as Parameters<HttpAgent["run"]>[0]);
}
}
function repackForwardedPropsIntoConfigurable<
T extends RunInputWithForwardedProps,
>(input: T): T {
const fp = (input.forwardedProps ?? {}) as Record<string, unknown>;
if (!fp || typeof fp !== "object") return input;
// Split forwardedProps into (structural) and (user-supplied) halves.
const userProps: Record<string, unknown> = {};
const structural: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fp)) {
if (RESERVED_FORWARDED_PROPS_KEYS.has(key)) {
structural[key] = value;
} else {
userProps[key] = value;
}
}
if (Object.keys(userProps).length === 0) return input;
const existingConfig = (structural.config ?? {}) as {
configurable?: Record<string, unknown>;
[k: string]: unknown;
};
const existingConfigurable =
(existingConfig.configurable as Record<string, unknown> | undefined) ?? {};
const existingProperties =
(existingConfigurable.properties as Record<string, unknown> | undefined) ??
{};
const mergedConfig = {
...existingConfig,
configurable: {
...existingConfigurable,
properties: {
...existingProperties,
...userProps,
},
},
};
return {
...input,
forwardedProps: {
...structural,
config: mergedConfig,
},
} as T;
}
// Trailing-slash mount root: src/agent_server.py mounts the agent-config
// FastAPI sub-app at /agent-config, and the sub-app mounts its AGUIStream
// at "/" (same shape as the multimodal agent).
const agentConfigAgent = new AgentConfigHttpAgent({
url: `${AGENT_URL}/agent-config/`,
});
const agents = {
"agent-config-demo": agentConfigAgent,
default: agentConfigAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- see main route.ts; published CopilotRuntime's `agents`
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
// plain Records. Fixed in source, pending release.
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
// Log full details server-side (operators grep `errorId` to correlate),
// but never echo `err.message` / `err.stack` back to the HTTP client —
// that leaks internal paths, dependency versions, and stack traces.
const err = error instanceof Error ? error : new Error(String(error));
const errorId = crypto.randomUUID();
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
scope: "copilotkit-agent-config/route",
errorId,
message: err.message,
stack: err.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
@@ -0,0 +1,58 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook, which runs before routing and can short-circuit the
// request by throwing a Response. We validate a static `Authorization: Bearer
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
// AG2 backend.
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Reuse the neutral default AG2 agent for the authenticated path. The
// point of this demo is the gate mechanism, not per-user agent branching.
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
const runtime = new CopilotRuntime({
agents: {
"auth-demo": authDemoAgent,
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
throw new Response(
JSON.stringify({
error: "unauthorized",
message:
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}
},
},
});
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
export const PUT = (req: NextRequest) => handler(req);
export const DELETE = (req: NextRequest) => handler(req);
@@ -0,0 +1,74 @@
// Dedicated runtime for the (simplified) Beautiful Chat showcase cell.
//
// Beautiful Chat combines TWO of the canonical reference's three flagship
// features in a single cell:
// - A2UI Dynamic Schema (branded React catalog, agent-owned `generate_a2ui`)
// - Open Generative UI (auto-injected `generateSandboxedUi` frontend tool)
//
// Splitting into its own endpoint matters because:
// - `openGenerativeUI` flips a global probe flag that, on the shared
// `/api/copilotkit` route, would wipe per-cell `useFrontendTool` /
// `useComponent` registrations (see comment in `copilotkit-ogui/route.ts`).
// - `a2ui.injectA2UITool: false` is required so the runtime doesn't
// double-bind a second A2UI tool over the agent-owned `generate_a2ui`.
//
// References:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-beautiful-chat/route.ts
// - src/app/api/copilotkit-declarative-gen-ui/route.ts (a2ui scoping pattern)
// - src/app/api/copilotkit-ogui/route.ts (openGenerativeUI scoping pattern)
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const beautifulChatAgent = new HttpAgent({
url: `${AGENT_URL}/beautiful-chat/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "beautiful-chat": beautifulChatAgent },
// The agent owns `generate_a2ui` explicitly (see
// src/agents/beautiful_chat.py). The runtime middleware still serialises
// the registered client catalog into agent context and detects
// `a2ui_operations` containers in the tool result; it just must NOT bind
// a second A2UI tool on top.
a2ui: {
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "copilotkit://app-dashboard-catalog",
},
// Turn on Open Generative UI for this agent. The runtime middleware
// injects `generateSandboxedUi` as a frontend tool the LLM can call,
// and converts streaming tool-call deltas into `open-generative-ui`
// activity events the built-in renderer mounts in a sandboxed iframe.
openGenerativeUI: {
agents: ["beautiful-chat"],
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-beautiful-chat",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,47 @@
// Dedicated runtime for the byoc-hashbrown demo (AG2).
//
// The demo page wraps CopilotChat in the HashBrownDashboard provider and
// overrides the assistant message slot with a renderer that consumes
// hashbrown-shaped structured output via `@hashbrownai/react`'s `useUiKit` +
// `useJsonParser`. The agent behind this endpoint (`byoc_hashbrown`) has a
// system prompt tuned to emit that shape — see
// `src/agents/byoc_hashbrown_agent.py`.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const byocHashbrownAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
agents: {
"byoc-hashbrown-demo": byocHashbrownAgent,
default: byocHashbrownAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,40 @@
// Dedicated runtime for the BYOC json-render demo (AG2).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const byocJsonRenderAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-json-render/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
agents: {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,59 @@
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
// cell. The backend is the dedicated `a2ui_dynamic.py` agent mounted at
// `/declarative-gen-ui` (NOT the root catch-all `agent.py`): it owns the
// `generate_a2ui` tool explicitly and runs its own secondary `render_a2ui`
// LLM pass, returning an `a2ui_operations` container that the A2UI
// middleware detects and streams to the frontend. This mirrors the sibling
// dedicated routes (`/a2ui-fixed-schema/`, `/beautiful-chat/`, etc.) which
// all point at their named mount, and matches the D6 fixtures + PARITY_NOTES.
//
// `injectA2UITool: false` — the agent already owns `generate_a2ui`, so the
// runtime must NOT double-bind a second injected A2UI tool over it.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
"declarative-gen-ui": new HttpAgent({
url: `${AGENT_URL}/declarative-gen-ui/`,
}),
},
a2ui: {
// The dedicated agent owns `generate_a2ui` and produces the
// `a2ui_operations` container itself; do not inject a second A2UI tool.
injectA2UITool: false,
// Pin the catalog the page registers (mirrors the sibling
// `/copilotkit-beautiful-chat` and `/copilotkit-a2ui-fixed-schema`
// routes). The agent's emitted ops already carry this catalogId, but
// pinning it guards against any op that omits it falling back to the
// unregistered basic catalog ("Catalog not found" → surface never mounts).
defaultCatalogId: "declarative-gen-ui-catalog",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-gen-ui",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,73 @@
// CopilotKit runtime for the MCP Apps cell.
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
// agent: when the agent calls a tool backed by an MCP UI resource, the
// middleware fetches the resource and emits the activity event that the
// built-in `MCPAppsActivityRenderer` renders in the chat as a sandboxed iframe.
//
// Reference:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-mcp-apps/route.ts
// - src/agents/mcp_apps_agent.py (the AG2 backend, no bespoke tools)
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/` });
const headlessCompleteAgent = new HttpAgent({
url: `${AGENT_URL}/headless-complete/`,
});
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware to every registered agent: on each
// MCP tool call it fetches the associated UI resource and emits an
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
// inline in the chat.
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
"mcp-apps": mcpAppsAgent,
// headless-complete shares this runtime because its cell also exercises
// MCP Apps rendering (via a hand-rolled `useRenderActivityMessage` in
// `use-rendered-messages.tsx`).
"headless-complete": headlessCompleteAgent,
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Pin a stable `serverId`. Without it CopilotKit hashes the URL and
// a URL change silently breaks restoration of persisted MCP Apps in
// prior conversation threads.
serverId: "excalidraw",
},
],
},
});
// @endregion[runtime-mcpapps-config]
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-mcp-apps",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,44 @@
// Dedicated runtime for the Multimodal Attachments demo (AG2).
//
// The backing AG2 agent runs gpt-4o (vision-capable). A dedicated route keeps
// vision cost scoped to this cell.
//
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="multimodal-demo"` (the slug registered below).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const multimodalAgent = new HttpAgent({ url: `${AGENT_URL}/multimodal/` });
const agents = {
"multimodal-demo": multimodalAgent,
default: multimodalAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,60 @@
// Dedicated runtime for the Open Generative UI demos.
//
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const openGenUiAgent = new HttpAgent({ url: `${AGENT_URL}/open-gen-ui/` });
const openGenUiAdvancedAgent = new HttpAgent({
url: `${AGENT_URL}/open-gen-ui-advanced/`,
});
const agents = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// @region[advanced-runtime-config]
// @region[minimal-runtime-flag]
// Server-side config is identical for the minimal and advanced cells —
// the advanced behaviour (sandbox -> host function calls) is wired
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
// the provider. The single `openGenerativeUI` flag below turns on
// Open Generative UI for the listed agent(s); the runtime middleware
// converts each agent's streamed `generateSandboxedUi` tool call into
// `open-generative-ui` activity events.
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: {
agents: ["open-gen-ui", "open-gen-ui-advanced"],
},
}),
// @endregion[minimal-runtime-flag]
// @endregion[advanced-runtime-config]
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,79 @@
// Dedicated runtime for the /demos/voice cell (AG2).
//
// Goals
// -----
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
this.delegate = apiKey
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
throw new Error(
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
"Set OPENAI_API_KEY to enable voice transcription.",
);
}
return this.delegate.transcribeFile(options);
}
}
// @endregion[transcription-service-guard]
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch
agents: {
"voice-demo": voiceDemoAgent,
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
export const POST = (req: NextRequest) => getHandler()(req);
export const GET = (req: NextRequest) => getHandler()(req);
export const PUT = (req: NextRequest) => getHandler()(req);
export const DELETE = (req: NextRequest) => getHandler()(req);
// @endregion[voice-runtime]
@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
// Per-request request/response logging is gated behind this flag (default off).
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
const ROUTE_DEBUG =
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
function createAgent(path = "/") {
return new HttpAgent({ url: `${AGENT_URL}${path}` });
}
// Register the same default agent under all shared names used by demo
// pages. AG2's AGUIStream wraps a single ConversableAgent; most names
// proxy to the same backend process. Frontend-only variations (slots,
// sidebar, CSS theming, headless chat, tool rendering wildcards, etc.)
// all reuse the shared `agent.py` ConversableAgent under a unique
// registered name.
const sharedAgentNames = [
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
// Frontend-only variants (Batch 1) — same ConversableAgent, different UI.
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"chat-customization-css",
"headless-simple",
"readonly-state-agent-context",
"tool-rendering-default-catchall",
"tool-rendering-custom-catchall",
"frontend_tools",
"frontend-tools-async",
"hitl-in-app",
"hitl-in-chat",
];
// Reasoning agent names — backed by the reasoning-enabled AG2 agent at
// /reasoning. Emits AG-UI REASONING_MESSAGE_* events that the frontend
// renders via the `reasoningMessage` slot (built-in card for
// `reasoning-default`, custom amber ReasoningBlock for `reasoning-custom`).
// The demo pages use the ids `reasoning-default` / `reasoning-custom`; both
// share the one reasoning backend. `agentic-chat-reasoning` and
// `reasoning-default-render` are legacy aliases kept for any cell that still
// references them.
const reasoningAgentNames = [
"reasoning-default",
"reasoning-custom",
"reasoning-default-render",
"agentic-chat-reasoning",
];
// Demos that own a dedicated FastAPI sub-app (mounted at a named path
// in `agent_server.py`). Each gets its own HttpAgent pointed at that
// path so its ContextVariables state slot is isolated from the shared
// default agent.
const dedicatedAgents: Record<string, string> = {
"shared-state-read-write": "/shared-state-read-write/",
subagents: "/subagents/",
"headless-complete": "/headless-complete/",
"tool-rendering-reasoning-chain": "/tool-rendering-reasoning-chain/",
"agent-config-demo": "/agent-config/",
"gen-ui-agent": "/gen-ui-agent/",
};
// Interrupt-adapted demos: gen-ui-interrupt and interrupt-headless share the
// same AG2 scheduling agent at /interrupt-adapted. The agent has tools=[];
// `schedule_meeting` is provided by the frontend via `useFrontendTool`.
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
const agents: Record<string, AbstractAgent> = {};
for (const name of sharedAgentNames) {
agents[name] = createAgent();
}
for (const name of reasoningAgentNames) {
agents[name] = createAgent("/reasoning/");
}
for (const [name, path] of Object.entries(dedicatedAgents)) {
agents[name] = createAgent(path);
}
for (const name of interruptAgentNames) {
agents[name] = createAgent("/interrupt-adapted/");
}
agents["default"] = createAgent();
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) => {
const url = req.url;
const contentType = req.headers.get("content-type");
if (ROUTE_DEBUG) {
console.log(
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
} else if (ROUTE_DEBUG) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
}
return response;
} catch (error: unknown) {
// Log full details server-side (operators grep `errorId` to correlate),
// but never echo `err.message` / `err.stack` back to the HTTP client —
// that leaks internal paths, dependency versions, and stack traces.
const err = error instanceof Error ? error : new Error(String(error));
const errorId = crypto.randomUUID();
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
scope: "copilotkit/route",
errorId,
message: err.message,
stack: err.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
export const GET = async () => {
if (ROUTE_DEBUG) {
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
}
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
} catch (e: unknown) {
agentStatus = `unreachable (${(e as Error).message})`;
}
return NextResponse.json({
status: "ok",
agent_url: AGENT_URL,
agent_status: agentStatus,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
},
});
};
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token =
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
const AGENT_URL = process.env.AGENT_URL || "unknown";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "ok" : "error";
agentDetail = `HTTP ${res.status}`;
} catch (e: unknown) {
agentStatus = "down";
agentDetail = (e as Error).message;
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "ag2",
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
nodeVersion: process.version,
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "ag2",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "ag2";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
`http://localhost:${process.env.PORT || 3000}`;
try {
const res = await fetch(`${baseUrl}/api/copilotkit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "agentic_chat" },
body: {
threadId: `smoke-${Date.now()}`,
runId: `smoke-run-${Date.now()}`,
state: {},
messages: [
{
id: `smoke-msg-${Date.now()}`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
// TTFB: read first chunk only to confirm SSE stream started, then cancel
const reader = res.body?.getReader();
if (!reader) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned no readable body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const { value, done } = await reader.read();
reader.cancel();
if (done || !value || value.length === 0) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout"))
stage = "timeout";
else if (
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
)
stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
}
@@ -0,0 +1,55 @@
// Shared fallback time-slot generator for the interrupt demos
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
// inside the interrupt payload — these fallbacks only run if the
// payload arrives without them. Generating relative to `Date.now()`
// keeps the fallback from rotting, which previously had hardcoded
// dates that decayed within a week of being authored.
export interface TimeSlot {
label: string;
iso: string;
}
function atLocal(date: Date, hour: number, minute = 0): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
0,
0,
);
}
function nextMonday(from: Date): Date {
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
// that's at LEAST 2 days away — otherwise "Monday" would collide
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
// Monday (offset would be 0). Mirrors interrupt_agent.py.
const day = from.getDay();
let offset = (1 - day + 7) % 7;
if (offset <= 1) offset += 7;
const next = new Date(from);
next.setDate(from.getDate() + offset);
return next;
}
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const monday = nextMonday(now);
const candidates: Array<[string, Date]> = [
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
["Monday 9:00 AM", atLocal(monday, 9)],
["Monday 3:30 PM", atLocal(monday, 15, 30)],
];
return candidates.map(([label, date]) => ({
label,
iso: date.toISOString(),
}));
}
@@ -0,0 +1,12 @@
// Coerces a tool-call result into a typed object. Tool results arrive
// as strings when the agent emits JSON or as already-parsed objects
// when the runtime decoded them upstream — this helper handles both
// shapes and returns `{}` if the result is missing or unparseable.
export function parseJsonResult<T>(result: unknown): T {
if (!result) return {} as T;
try {
return (typeof result === "string" ? JSON.parse(result) : result) as T;
} catch {
return {} as T;
}
}
@@ -0,0 +1,21 @@
// Helper for the CopilotChat slot overrides. The slot prop types in
// `@copilotkit/react-core` are nominally typed against the *exact*
// default component identity, but a custom wrapper that returns a
// structurally compatible ReactElement is functionally a drop-in. This
// helper names that fact and centralizes the type assertion in one
// place — readers see `makeSlotOverride` and know it's about the slot
// contract, not arbitrary type-system gymnastics. Once the slot prop
// types accept structural compatibility, this helper can be deleted
// and the casts will resolve automatically.
import type { ComponentType } from "react";
// `any` on the input is intentional: the helper's purpose is to accept
// any component shape and assert it as the slot's expected type. A
// stricter constraint would defeat the whole point.
export function makeSlotOverride<TDefault>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>,
): TDefault {
return component as unknown as TDefault;
}
@@ -0,0 +1,31 @@
import * as React from "react";
/**
* ShadCN-style Badge primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "secondary" | "outline" | "success";
const variantClasses: Record<Variant, string> = {
default: "border-transparent bg-neutral-900 text-neutral-50",
secondary: "border-transparent bg-neutral-100 text-neutral-900",
outline: "border-neutral-200 text-neutral-700 bg-white",
success: "border-transparent bg-emerald-100 text-emerald-700",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: Variant;
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
return (
<div
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import * as React from "react";
/**
* ShadCN-style Button primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
type Size = "default" | "sm" | "lg";
const baseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
const variantClasses: Record<Variant, string> = {
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
outline:
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
success:
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
};
const sizeClasses: Record<Size, string> = {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-6",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = "", variant = "default", size = "default", ...props },
ref,
) => {
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
},
);
Button.displayName = "Button";
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* ShadCN-style Card primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-5 pt-0 ${className}`}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
@@ -0,0 +1,26 @@
import * as React from "react";
/**
* ShadCN-style Separator primitive (inline-cloned for this demo).
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
*/
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className = "",
orientation = "horizontal",
...props
}: SeparatorProps) {
const orientationClasses =
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
return (
<div
role="separator"
aria-orientation={orientation}
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,23 @@
"use client";
/**
* Fixed A2UI catalog — wires definitions to renderers.
*
* `includeBasicCatalog: true` merges CopilotKit's built-in components
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
* compose custom and basic components interchangeably.
*/
// @region[catalog-creation]
import { createCatalog } from "@copilotkit/a2ui-renderer";
import { definitions } from "./definitions";
import { renderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,107 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a component name + its Zod props schema. The basic
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
* we only declare the project-specific additions and the visual overrides
* here. (Custom entries with the same name as a basic component override
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
*
* IMPORTANT — path bindings: fields that can be bound to a data-model path
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
* and resolve the path against the current data model at render time. Using
* plain `z.string()` causes the raw `{ path }` object to reach the
* renderer, which React then throws on (error #31 "object with keys {path}").
* This matches the canonical catalog's `DynString` helper:
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
*/
// @region[definitions-types]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
/**
* Card override: gives the outer flight-card container a ShadCN look
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
* Card uses inline styles; overriding here lets the demo's renderer
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type Definitions = typeof definitions;
@@ -0,0 +1,110 @@
"use client";
/**
* A2UI catalog RENDERERS — React implementations for the custom components
* declared in `./definitions`. TypeScript enforces that the renderer map's
* keys and prop shapes match the definitions exactly.
*
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
* borders, clean typography). Tailwind utility classes only — no `cn()` /
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
* `../_components/`.
*/
import React from "react";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { Definitions } from "./definitions";
import { Card } from "../_components/card";
import { Badge } from "../_components/badge";
import { Button as UIButton } from "../_components/button";
import { Separator } from "../_components/separator";
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
// the A2UI binder resolves path bindings before render — renderers only ever
// see resolved strings. One shared helper keeps that narrowing in one place.
const s = (v: unknown): string => (typeof v === "string" ? v : "");
// @region[renderers-tsx]
export const renderers: CatalogRenderers<Definitions> = {
/**
* Card override: ShadCN-style outer container. The basic catalog's Card
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
* The flight schema renders Card > Column > [Title, Row, …]; the inner
* Column adds the vertical spacing.
*/
Card: ({ props, children }) => (
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
{props.child ? children(props.child) : null}
</Card>
),
Title: ({ props }) => (
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Itinerary
</p>
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
{s(props.text)}
</h3>
</div>
<Badge variant="outline" className="font-mono">
1-stop · economy
</Badge>
</div>
),
Airport: ({ props }) => (
<div className="flex flex-col items-center">
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
{s(props.code)}
</span>
</div>
),
Arrow: () => (
<div className="flex flex-1 items-center px-3">
<Separator className="flex-1 bg-neutral-200" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mx-1 text-neutral-400"
aria-hidden
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
<Separator className="flex-1 bg-neutral-200" />
</div>
),
AirlineBadge: ({ props }) => (
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
{s(props.name)}
</Badge>
),
PriceTag: ({ props }) => (
<div className="flex items-baseline gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Total
</span>
<span className="font-mono text-base font-semibold text-neutral-900">
{s(props.amount)}
</span>
</div>
),
/**
* Button override: this is a pure-presentation demo, so the button just
* renders its label. The schema declares an `action` for visual fidelity,
* but the click handler is inert until the Python SDK exposes
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
*/
Button: ({ props, children }) => (
<UIButton className="w-full">
{props.child ? children(props.child) : null}
</UIButton>
),
};
// @endregion[renderers-tsx]
@@ -0,0 +1,11 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
export function Chat() {
useA2UIFixedSchemaSuggestions();
return (
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
);
}
@@ -0,0 +1,41 @@
"use client";
/**
* Declarative Generative UI — A2UI Fixed Schema demo.
*
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
* the frontend and the agent only streams *data* into the data model. The
* flight card is ASSEMBLED from small sub-components in
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
*
* - Definitions (zod schemas): `./a2ui/definitions.ts`
* - Renderers (React): `./a2ui/renderers.tsx`
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { catalog } from "./a2ui/catalog";
import { Chat } from "./chat";
export default function A2UIFixedSchemaDemo() {
return (
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
<CopilotKit
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
agent="a2ui-fixed-schema"
a2ui={{ catalog: catalog }}
>
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
<Chat />
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,13 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useA2UIFixedSchemaSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Find SFO → JFK",
message: "Find me a flight from SFO to JFK on United for $289.",
},
],
available: "always",
});
}
@@ -0,0 +1,91 @@
"use client";
import type { ChangeEvent } from "react";
import {
type AgentConfig,
EXPERTISE_OPTIONS,
type Expertise,
RESPONSE_LENGTH_OPTIONS,
type ResponseLength,
TONE_OPTIONS,
type Tone,
} from "./config-types";
interface ConfigCardProps {
config: AgentConfig;
onToneChange: (tone: Tone) => void;
onExpertiseChange: (expertise: Expertise) => void;
onResponseLengthChange: (length: ResponseLength) => void;
}
export function ConfigCard({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: ConfigCardProps) {
return (
<div
data-testid="agent-config-card"
className="flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
>
<h2 className="text-sm font-semibold">Agent Config</h2>
<p className="text-xs text-[var(--text-muted)]">
Change these and send a message to see the agent adapt.
</p>
<div className="flex flex-wrap gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Tone</span>
<select
data-testid="agent-config-tone-select"
value={config.tone}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onToneChange(e.target.value as Tone)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{TONE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Expertise</span>
<select
data-testid="agent-config-expertise-select"
value={config.expertise}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onExpertiseChange(e.target.value as Expertise)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{EXPERTISE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Response length</span>
<select
data-testid="agent-config-length-select"
value={config.responseLength}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onResponseLengthChange(e.target.value as ResponseLength)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
/**
* Publishes the current agent-config toggles to the agent runtime via
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
* context store is reachable. The middleware on the Python side reads
* this entry off the agent's runtime context on every turn and routes
* it into the model's prompt.
*/
import { useAgentContext } from "@copilotkit/react-core/v2";
import type { AgentConfig } from "./config-types";
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
useAgentContext({
description:
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
value: {
tone: config.tone,
expertise: config.expertise,
responseLength: config.responseLength,
},
});
return null;
}
@@ -0,0 +1,26 @@
export type Tone = "professional" | "casual" | "enthusiastic";
export type Expertise = "beginner" | "intermediate" | "expert";
export type ResponseLength = "concise" | "detailed";
export interface AgentConfig {
tone: Tone;
expertise: Expertise;
responseLength: ResponseLength;
}
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
tone: "professional",
expertise: "intermediate",
responseLength: "concise",
};
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
export const EXPERTISE_OPTIONS: Expertise[] = [
"beginner",
"intermediate",
"expert",
];
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
"concise",
"detailed",
];
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { ConfigCard } from "./config-card";
import type { AgentConfig } from "./config-types";
interface DemoLayoutProps {
config: AgentConfig;
onToneChange: (tone: AgentConfig["tone"]) => void;
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
}
export function DemoLayout({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: DemoLayoutProps) {
return (
<div className="flex h-screen flex-col gap-3 p-6">
<ConfigCard
config={config}
onToneChange={onToneChange}
onExpertiseChange={onExpertiseChange}
onResponseLengthChange={onResponseLengthChange}
/>
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
<CopilotChat
agentId="agent-config-demo"
className="h-full rounded-md"
/>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
/**
* Agent Config Object — typed config knobs (tone / expertise / responseLength)
* forwarded from the provider into the agent so its behavior changes per turn.
*
* Wiring: the toggles live in `useAgentConfig`. Each render the resolved
* config is published to the agent via `useAgentContext` — the v2 idiom
* for "frontend → agent runtime context" in LangGraph 0.6+. The Python
* graph picks it up through `CopilotKitMiddleware`, which routes the
* context entry into the model's prompt before each call.
*
* (LangGraph 0.6 deprecated `configurable` in favor of `context`; the
* `properties` prop on `<CopilotKit>` still works for v1-style relays
* but goes through `forwardedProps` and does not land in `RunnableConfig`
* in @ag-ui/langgraph 0.0.31. `useAgentContext` is the supported path.)
*/
import { CopilotKit } from "@copilotkit/react-core/v2";
import { DemoLayout } from "./demo-layout";
import { ConfigContextRelay } from "./config-context-relay";
import { useAgentConfig } from "./use-agent-config";
export default function AgentConfigDemoPage() {
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
return (
// @region[provider-setup]
<CopilotKit
runtimeUrl="/api/copilotkit-agent-config"
agent="agent-config-demo"
>
<ConfigContextRelay config={config} />
<DemoLayout
config={config}
onToneChange={setTone}
onExpertiseChange={setExpertise}
onResponseLengthChange={setResponseLength}
/>
</CopilotKit>
// @endregion[provider-setup]
);
}
@@ -0,0 +1,39 @@
"use client";
import { useCallback, useState } from "react";
import {
type AgentConfig,
DEFAULT_AGENT_CONFIG,
type Expertise,
type ResponseLength,
type Tone,
} from "./config-types";
export interface UseAgentConfigHandle {
config: AgentConfig;
setTone: (tone: Tone) => void;
setExpertise: (expertise: Expertise) => void;
setResponseLength: (length: ResponseLength) => void;
reset: () => void;
}
export function useAgentConfig(): UseAgentConfigHandle {
const [config, setConfig] = useState<AgentConfig>(DEFAULT_AGENT_CONFIG);
const setTone = useCallback(
(tone: Tone) => setConfig((prev) => ({ ...prev, tone })),
[],
);
const setExpertise = useCallback(
(expertise: Expertise) => setConfig((prev) => ({ ...prev, expertise })),
[],
);
const setResponseLength = useCallback(
(responseLength: ResponseLength) =>
setConfig((prev) => ({ ...prev, responseLength })),
[],
);
const reset = useCallback(() => setConfig(DEFAULT_AGENT_CONFIG), []);
return { config, setTone, setExpertise, setResponseLength, reset };
}
@@ -0,0 +1,28 @@
# Agentic Chat
## What This Demo Shows
The simplest CopilotKit surface: a plain agentic chat backed by a LangGraph (Python) agent.
- **Natural Conversation**: Chat with your Copilot in a familiar chat interface
- **Streaming Responses**: Assistant messages stream in token-by-token via AG-UI
- **Suggestion Chips**: A starter suggestion is rendered as a quick-action chip
## How to Interact
Click the suggestion chip, or type your own prompt. For example:
- "Write a short sonnet about AI"
- "Explain the difference between an LLM and an agent"
- "Give me three ideas for a weekend project"
## Technical Details
**Provider**`CopilotKit` wires the page to the runtime:
- `runtimeUrl="/api/copilotkit"` points at the Next.js route that proxies to the agent
- `agent="agentic_chat"` selects the LangGraph agent defined in `langgraph.json`
**Chat surface**`CopilotChat` renders the full chat UI with input, message list, and streaming.
**Suggestions**`useConfigureSuggestions` registers a static suggestion that appears as a clickable chip below the chat input.
@@ -0,0 +1,24 @@
"use client";
import React from "react";
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
import { useAgenticChatSuggestions } from "./suggestions";
export default function AgenticChatDemo() {
return (
// @region[provider-setup]
<CopilotKit runtimeUrl="/api/copilotkit" agent="agentic_chat">
<Chat />
</CopilotKit>
// @endregion[provider-setup]
);
}
// @region[chat-component]
function Chat() {
useAgenticChatSuggestions();
// @region[render-chat]
return <CopilotChat agentId="agentic_chat" />;
// @endregion[render-chat]
}
// @endregion[chat-component]
@@ -0,0 +1,22 @@
"use client";
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
// @region[configure-suggestions]
export function useAgenticChatSuggestions() {
useConfigureSuggestions({
suggestions: [
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
{
title: "Tell me a joke",
message: "Tell me a one-line joke.",
},
{
title: "Is 17 prime?",
message: "Walk me through whether 17 is prime.",
},
],
available: "always",
});
}
// @endregion[configure-suggestions]
@@ -0,0 +1,63 @@
"use client";
import { Button } from "@/components/ui/button";
interface AuthBannerProps {
authenticated: boolean;
onSignOut: () => void;
onSignIn: () => void;
}
/**
* Status strip rendered above <CopilotChat /> in both authenticated and
* post-sign-out states. The post-sign-out (amber) variant exists so the demo
* actually showcases what its name promises — the runtime rejecting an
* unauthenticated request — instead of bouncing the user back to the gate
* page where the rejection never happens.
*
* Pure presentational — owns no state itself. Testids are stable contract
* for QA + Playwright specs.
*/
export function AuthBanner({
authenticated,
onSignOut,
onSignIn,
}: AuthBannerProps) {
const classes = authenticated
? "border-emerald-300 bg-emerald-50 text-emerald-900"
: "border-amber-300 bg-amber-50 text-amber-900";
return (
<div
data-testid="auth-banner"
data-authenticated={authenticated ? "true" : "false"}
className={`flex items-center justify-between gap-3 rounded-md border px-4 py-2 text-sm ${classes}`}
>
<span data-testid="auth-status" className="font-medium">
{authenticated
? "✓ Signed in as demo user"
: "⚠ Signed out — the agent will reject your messages until you sign in."}
</span>
{authenticated ? (
<Button
type="button"
data-testid="auth-sign-out-button"
size="sm"
variant="outline"
onClick={onSignOut}
>
Sign out
</Button>
) : (
<Button
type="button"
data-testid="auth-authenticate-button"
size="sm"
onClick={onSignIn}
>
Sign in
</Button>
)}
</div>
);
}
@@ -0,0 +1,11 @@
/**
* Shared demo-token constant imported by both the client
* (use-demo-auth.ts) and the server runtime route
* (api/copilotkit-auth/route.ts). Keeping the constant in one file
* prevents drift: changing the token in one place changes it everywhere.
*
* This is a DEMO token. Never use a hard-coded shared secret for real auth.
*/
export const DEMO_TOKEN = "demo-token-123";
export const DEMO_AUTH_HEADER = `Bearer ${DEMO_TOKEN}`;
@@ -0,0 +1,147 @@
"use client";
// Auth demo — framework-native request authentication via the V2 runtime's
// `onRequest` hook. The runtime route (/api/copilotkit-auth) rejects any
// request whose `Authorization: Bearer <demo-token>` header is missing or
// wrong.
//
// UX shape: the demo defaults to UNAUTHENTICATED on first paint so visitors
// land on a clear sign-in card. We don't render `<CopilotKit>` until the user
// has signed in at least once — that sidesteps the transport 401 that would
// otherwise crash `<CopilotChat>` during its initial `/info` handshake.
// After the user signs in once, `<CopilotKit>` stays mounted across the
// sign-out → sign-in cycle so the post-sign-out state can actually
// demonstrate the runtime rejecting unauthenticated requests in the chat
// surface (the whole point of the demo).
//
// Error surfacing: the post-sign-out 401 is captured via the AGENT-SCOPED
// `<CopilotChat onError>` channel, NOT the provider-level `<CopilotKit
// onError>` alone. Agent-run errors (`agent_run_failed`) are reliably
// delivered to the chat-scoped subscription, whereas the provider-level
// handler does not fire for them in this flow — so a demo that relies only
// on `<CopilotKit onError>` never renders the rejection banner. We register
// the same handler on BOTH channels: `<CopilotKit onError>` covers any
// provider-level errors (e.g. the initial `/info` handshake) and
// `<CopilotChat onError>` covers agent-run rejections, which is what the
// sign-out path produces.
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CopilotKit,
CopilotChat,
type CopilotKitCoreErrorCode,
} from "@copilotkit/react-core/v2";
import { AuthBanner } from "./auth-banner";
import { SignInCard } from "./sign-in-card";
import { useDemoAuth } from "./use-demo-auth";
import { DEMO_TOKEN } from "./demo-token";
interface AuthDemoErrorState {
message: string;
code: CopilotKitCoreErrorCode | string;
}
interface AuthErrorEvent {
error?: { message?: string } | null;
code: CopilotKitCoreErrorCode;
}
export default function AuthDemoPage() {
const {
isAuthenticated,
authorizationHeader,
hasEverSignedIn,
signIn,
signOut,
} = useDemoAuth();
const headers = useMemo<Record<string, string>>(
() => (authorizationHeader ? { Authorization: authorizationHeader } : {}),
[authorizationHeader],
);
const [authError, setAuthError] = useState<AuthDemoErrorState | null>(null);
// Shared error handler wired to BOTH the provider-level and chat-level
// `onError` channels (see the file header for why both are needed).
const handleAuthError = useCallback((event: AuthErrorEvent) => {
setAuthError({
message:
(event.error?.message && event.error.message.trim()) ||
(event.code
? `Request rejected (${event.code})`
: "The request was rejected."),
code: event.code,
});
}, []);
// Clear stale errors as soon as the user re-authenticates. This is the
// ONLY thing that gates the amber error surface on auth state — the render
// condition below keys off `authError` alone. Coupling the render to a
// second `!isAuthenticated` slice (the obvious-but-wrong guard) created a
// post-sign-out race: the rejection's `onError` fires and calls
// `setAuthError`, but if that commit landed in a render where the auth
// state hadn't yet settled to false, `authError && !isAuthenticated`
// evaluated false and the banner never appeared. Driving the surface off
// `authError` and clearing it here on re-auth removes the cross-slice
// ordering dependency: a rejection always renders, and signing back in
// always wipes it.
useEffect(() => {
if (isAuthenticated) setAuthError(null);
}, [isAuthenticated]);
if (!hasEverSignedIn) {
return (
<div className="flex h-screen flex-col">
<SignInCard onSignIn={signIn} />
</div>
);
}
return (
// `useSingleEndpoint={false}` opts into the V2 multi-endpoint protocol
// (separate /info, /agents/<id>/run, etc.), which is what this demo's
// runtime route is wired up for.
<CopilotKit
runtimeUrl="/api/copilotkit-auth"
agent="auth-demo"
headers={headers}
useSingleEndpoint={false}
onError={handleAuthError}
>
<div className="flex h-screen flex-col gap-3 p-6">
<AuthBanner
authenticated={isAuthenticated}
onSignOut={signOut}
onSignIn={() => signIn(DEMO_TOKEN)}
/>
<header>
<h1 className="text-lg font-semibold">Authentication</h1>
</header>
{authError && (
<div
data-testid="auth-demo-error"
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<strong className="font-semibold">
Runtime rejected the request:
</strong>{" "}
<span data-testid="auth-demo-error-message">
{authError.message}
</span>{" "}
<code className="ml-1 rounded bg-amber-100 px-1 py-0.5 font-mono text-xs">
{authError.code}
</code>
</div>
)}
<div className="flex-1 overflow-hidden rounded-md border border-neutral-200">
<CopilotChat
agentId="auth-demo"
className="h-full"
onError={handleAuthError}
/>
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,70 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DEMO_TOKEN } from "./demo-token";
interface SignInCardProps {
onSignIn: (token: string) => void;
}
/**
* Unauthenticated landing card for the auth demo. Surfaces the demo bearer
* token in plain text so visitors can see exactly what gets sent on the
* `Authorization` header — there's no real form because the value is fixed
* by the runtime gate. Clicking "Sign in" stores the token via
* `useDemoAuth()`, which causes the parent to mount `<CopilotKit>`.
*/
export function SignInCard({ onSignIn }: SignInCardProps) {
return (
<div className="flex h-full items-center justify-center p-6">
<Card data-testid="auth-sign-in-card" className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign in to start chatting</CardTitle>
<CardDescription>
The runtime rejects requests without an{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
Authorization
</code>{" "}
header. Sign in below to mount the chat with a demo bearer token
attached.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Demo token
</p>
<code
data-testid="auth-demo-token"
className="mt-1 block rounded-md border bg-muted px-3 py-2 font-mono text-sm"
>
{DEMO_TOKEN}
</code>
</div>
<p className="text-xs text-muted-foreground">
Real apps should issue per-user tokens via your identity provider
and never hard-code shared secrets.
</p>
</CardContent>
<CardFooter>
<Button
type="button"
data-testid="auth-sign-in-button"
className="w-full"
onClick={() => onSignIn(DEMO_TOKEN)}
>
Sign in with demo token
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DEMO_TOKEN } from "./demo-token";
const STORAGE_KEY = "copilotkit:auth-demo:token";
export interface DemoAuthHandle {
isAuthenticated: boolean;
/** The token string when authenticated, otherwise null. */
token: string | null;
/** The full `Bearer <token>` value when authenticated, otherwise null. */
authorizationHeader: string | null;
/**
* Whether the user has signed in at least once during the current page
* session. Used by the page to decide between the first-paint SignInCard
* (never signed in) and the persistent chat-with-amber-banner state
* (signed in, then signed out) — the latter is the only state that
* actually showcases the runtime rejecting unauthenticated requests.
* Resets on full page reload by design.
*/
hasEverSignedIn: boolean;
/** Sign in with the provided token. */
signIn: (token: string) => void;
/** Clear the stored token. */
signOut: () => void;
}
/**
* Persistent demo auth state for the /demos/auth showcase cell. Tokens are
* stored in localStorage so a page reload doesn't kick the user back out;
* first paint of a fresh visitor is unauthenticated, which lets the demo
* showcase its sign-in CTA up front.
*
* This is a DEMO. Never store real bearer tokens in localStorage in a
* production application — that exposes them to any script running on the
* page.
*/
export function useDemoAuth(): DemoAuthHandle {
const [token, setToken] = useState<string | null>(null);
const [hasEverSignedIn, setHasEverSignedIn] = useState(false);
// Hydrate from localStorage after mount. Reading on initial render would
// mismatch SSR (where window is undefined); deferring to useEffect keeps
// first paint unauthenticated and avoids hydration warnings.
useEffect(() => {
if (typeof window === "undefined") return;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored) {
setToken(stored);
setHasEverSignedIn(true);
}
} catch {
// localStorage unavailable (privacy mode, etc.) — fall back to
// in-memory only.
}
}, []);
const signIn = useCallback((nextToken: string) => {
setToken(nextToken);
setHasEverSignedIn(true);
try {
window.localStorage.setItem(STORAGE_KEY, nextToken);
} catch {
// Ignore — in-memory state still works.
}
}, []);
const signOut = useCallback(() => {
setToken(null);
// hasEverSignedIn intentionally stays true so the page keeps showing
// the chat surface (with amber banner) after sign-out. That is the
// state that demonstrates the runtime returning 401.
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore.
}
}, []);
// The runtime gate compares against a fixed token, so anything other than
// DEMO_TOKEN won't actually authenticate against the API. We still allow
// arbitrary strings here because validation is the runtime's job — the UI
// just owns "what header are we sending".
return {
isAuthenticated: token !== null,
token,
authorizationHeader: token ? `Bearer ${token}` : null,
hasEverSignedIn,
signIn,
signOut,
};
}
@@ -0,0 +1,20 @@
"use client";
import { useAgent } from "@copilotkit/react-core/v2";
import { TodoList } from "./todo-list";
export function ExampleCanvas() {
const { agent } = useAgent({ agentId: "beautiful-chat" });
return (
<div className="h-full overflow-y-auto bg-[--background]">
<div className="max-w-4xl mx-auto px-8 py-10 h-full">
<TodoList
todos={agent.state?.todos || []}
onUpdate={(updatedTodos) => agent.setState({ todos: updatedTodos })}
isAgentRunning={agent.isRunning}
/>
</div>
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Card } from "../ui/card";
import { Checkbox } from "../ui/checkbox";
import { Button } from "../ui/button";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
interface Todo {
id: string;
title: string;
description: string;
emoji: string;
status: "pending" | "completed";
}
interface TodoCardProps {
todo: Todo;
onToggleStatus: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateDescription: (todoId: string, description: string) => void;
onUpdateEmoji: (todoId: string, emoji: string) => void;
}
const EMOJI_OPTIONS = ["✅", "🔥", "🎯", "💡", "🚀"];
export function TodoCard({
todo,
onToggleStatus,
onDelete,
onUpdateTitle,
onUpdateDescription,
onUpdateEmoji,
}: TodoCardProps) {
const [editingField, setEditingField] = useState<
"title" | "description" | null
>(null);
const [editValue, setEditValue] = useState("");
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isCompleted = todo.status === "completed";
const truncatedDescription =
todo.description.length > 120
? todo.description.slice(0, 120) + "..."
: todo.description;
const startEdit = (field: "title" | "description") => {
setEditingField(field);
setEditValue(field === "title" ? todo.title : todo.description);
};
const saveEdit = (field: "title" | "description") => {
if (editValue.trim()) {
if (field === "title") {
onUpdateTitle(todo.id, editValue.trim());
} else {
onUpdateDescription(todo.id, editValue.trim());
}
}
setEditingField(null);
setEditValue("");
};
const cancelEdit = () => {
setEditingField(null);
setEditValue("");
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height =
textareaRef.current.scrollHeight + "px";
}
}, [editValue]);
return (
<Card
className={cn(
"group relative p-5 transition-all duration-150",
isCompleted && "opacity-60",
)}
>
{/* Delete — top right on hover */}
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(todo)}
className="absolute top-3 right-3 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Delete todo"
>
<X className="h-3.5 w-3.5" />
</Button>
{/* Emoji avatar */}
<div className="relative inline-block mb-3">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className={cn(
"block text-3xl leading-none cursor-pointer rounded-xl p-2 transition-colors",
isCompleted ? "bg-[var(--muted)]" : "bg-[var(--secondary)]",
)}
aria-label="Change emoji"
>
{todo.emoji}
</button>
{showEmojiPicker && (
<div className="absolute top-0 left-full ml-2 z-10 flex gap-1 p-1.5 rounded-full bg-[var(--card)] border border-[var(--border)] shadow-lg">
{EMOJI_OPTIONS.map((emoji) => (
<button
key={emoji}
onClick={() => {
onUpdateEmoji(todo.id, emoji);
setShowEmojiPicker(false);
}}
className="text-lg w-8 h-8 flex items-center justify-center rounded-full cursor-pointer transition-colors hover:bg-[var(--secondary)]"
>
{emoji}
</button>
))}
</div>
)}
</div>
{/* Title */}
<div className="flex items-start gap-3">
<Checkbox
checked={isCompleted}
onCheckedChange={() => onToggleStatus(todo)}
className="mt-[2px]"
/>
<div className="flex-1 min-w-0">
{editingField === "title" ? (
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveEdit("title")}
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit("title");
if (e.key === "Escape") cancelEdit();
}}
className="w-full text-base font-semibold focus:outline-none bg-transparent text-[var(--foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
autoFocus
aria-label="Edit todo title"
/>
) : (
<div
onClick={() => startEdit("title")}
className={cn(
"text-base font-semibold cursor-text break-words leading-snug",
isCompleted
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--foreground)]",
)}
>
{todo.title}
</div>
)}
{editingField === "description" ? (
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveEdit("description")}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit();
}}
className="w-full mt-1.5 text-sm leading-relaxed focus:outline-none resize-none bg-transparent text-[var(--muted-foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
rows={1}
autoFocus
aria-label="Edit todo description"
/>
) : (
<p
onClick={() => startEdit("description")}
className={cn(
"mt-1.5 text-sm leading-relaxed cursor-text",
isCompleted
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--muted-foreground)]",
)}
>
{truncatedDescription}
</p>
)}
</div>
</div>
</Card>
);
}
@@ -0,0 +1,88 @@
"use client";
import { TodoCard } from "./todo-card";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Plus } from "lucide-react";
interface Todo {
id: string;
title: string;
description: string;
emoji: string;
status: "pending" | "completed";
}
interface TodoColumnProps {
title: string;
todos: Todo[];
emptyMessage: string;
showAddButton?: boolean;
onAddTodo?: () => void;
onToggleStatus: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateDescription: (todoId: string, description: string) => void;
onUpdateEmoji: (todoId: string, emoji: string) => void;
isAgentRunning: boolean;
}
export function TodoColumn({
title,
todos,
emptyMessage,
showAddButton = false,
onAddTodo,
onToggleStatus,
onDelete,
onUpdateTitle,
onUpdateDescription,
onUpdateEmoji,
isAgentRunning,
}: TodoColumnProps) {
return (
<section aria-label={`${title} column`} className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold tracking-tight text-[var(--foreground)]">
{title}
</h2>
<Badge variant="secondary">{todos.length}</Badge>
</div>
{showAddButton && onAddTodo && (
<Button
variant="ghost"
size="icon"
onClick={onAddTodo}
disabled={isAgentRunning}
aria-label="Add new todo"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
{/* Cards */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="text-center text-sm rounded-[var(--radius)] border-2 border-dashed border-[var(--border)] p-5 min-h-[151px] flex items-center justify-center text-[var(--muted-foreground)]">
{emptyMessage}
</div>
) : (
todos.map((todo) => (
<TodoCard
key={todo.id}
todo={todo}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
onUpdateTitle={onUpdateTitle}
onUpdateDescription={onUpdateDescription}
onUpdateEmoji={onUpdateEmoji}
/>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,115 @@
"use client";
import { TodoColumn } from "./todo-column";
import { Button } from "../ui/button";
interface Todo {
id: string;
title: string;
description: string;
emoji: string;
status: "pending" | "completed";
}
interface TodoListProps {
todos: Todo[];
onUpdate: (todos: Todo[]) => void;
isAgentRunning: boolean;
}
export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
const pendingTodos = todos.filter((t) => t.status === "pending");
const completedTodos = todos.filter((t) => t.status === "completed");
const toggleStatus = (todo: Todo) => {
const updated = todos.map((t) =>
t.id === todo.id
? {
...t,
status: (t.status === "completed" ? "pending" : "completed") as
| "pending"
| "completed",
}
: t,
);
onUpdate(updated);
};
const deleteTodo = (todo: Todo) => {
onUpdate(todos.filter((t) => t.id !== todo.id));
};
const updateTitle = (todoId: string, title: string) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, title } : t));
onUpdate(updated);
};
const updateDescription = (todoId: string, description: string) => {
const updated = todos.map((t) =>
t.id === todoId ? { ...t, description } : t,
);
onUpdate(updated);
};
const updateEmoji = (todoId: string, emoji: string) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, emoji } : t));
onUpdate(updated);
};
const addTodo = () => {
const newTodo: Todo = {
id: crypto.randomUUID(),
title: "New Todo",
description: "Add a description",
emoji: "🎯",
status: "pending",
};
onUpdate([...todos, newTodo]);
};
if (!todos || todos.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-5xl"></div>
<p className="text-base font-semibold text-[--foreground]">
No todos yet
</p>
<p className="text-sm text-[--muted-foreground]">
Create your first task to get started
</p>
<Button onClick={addTodo} disabled={isAgentRunning} className="mt-2">
Add a task
</Button>
</div>
);
}
return (
<div className="flex gap-8 h-full">
<TodoColumn
title="To Do"
todos={pendingTodos}
emptyMessage="No pending todos"
showAddButton
onAddTodo={addTodo}
onToggleStatus={toggleStatus}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateDescription={updateDescription}
onUpdateEmoji={updateEmoji}
isAgentRunning={isAgentRunning}
/>
<TodoColumn
title="Done"
todos={completedTodos}
emptyMessage="No completed todos yet"
onToggleStatus={toggleStatus}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateDescription={updateDescription}
onUpdateEmoji={updateEmoji}
isAgentRunning={isAgentRunning}
/>
</div>
);
}
@@ -0,0 +1,68 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { ModeToggle } from "./mode-toggle";
import { useFrontendTool } from "@copilotkit/react-core/v2";
interface ExampleLayoutProps {
chatContent: ReactNode;
appContent: ReactNode;
}
export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
const [mode, setMode] = useState<"chat" | "app">("chat");
useFrontendTool({
name: "enableAppMode",
description:
"Enable app mode, make sure its open when interacting with todos.",
handler: async () => {
setMode("app");
},
});
useFrontendTool({
name: "enableChatMode",
description: "Enable chat mode",
handler: async () => {
setMode("chat");
},
});
return (
<div className="h-full flex flex-row pb-6">
<ModeToggle mode={mode} onModeChange={setMode} />
{/* Chat Content */}
<div
className={`max-h-full flex flex-col dark:bg-stone-950 ${
mode === "app"
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
: "flex-1 max-lg:px-4"
}`}
>
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5">CopilotKit</span>
<img
src="/copilotkit-logo-mark.svg"
alt="CopilotKit"
className="h-7"
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">{chatContent}</div>
</div>
{/* State Panel */}
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Full width on mobile
: "w-0 border-l-0"
}`}
>
<div className="w-full lg:w-[66.666vw] h-full">{appContent}</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
interface ModeToggleProps {
mode: "chat" | "app";
onModeChange: (mode: "chat" | "app") => void;
}
export function ModeToggle({ mode, onModeChange }: ModeToggleProps) {
return (
<div className="fixed top-4 right-4 z-50 flex rounded-full border border-[var(--border)] bg-[var(--secondary)] p-0.5 max-lg:top-2 max-lg:right-2 max-lg:scale-90">
<button
onClick={() => onModeChange("chat")}
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
mode === "chat"
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
: "text-[var(--muted-foreground)]"
}`}
>
Chat
</button>
<button
onClick={() => onModeChange("app")}
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
mode === "app"
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
: "text-[var(--muted-foreground)]"
}`}
>
App
</button>
</div>
);
}
@@ -0,0 +1,163 @@
import { useRef } from "react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { z } from "zod";
import { CHART_COLORS, CHART_CONFIG } from "./config";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "../../ui/card";
import { BarChart3 } from "lucide-react";
export const BarChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type BarChartProps = z.infer<typeof BarChartProps>;
/** Tracks seen indices so only NEW bars get the fade-in animation. */
function useSeenIndices() {
const seen = useRef(new Set<number>());
return {
isNew(index: number) {
if (seen.current.has(index)) return false;
seen.current.add(index);
return true;
},
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AnimatedBar(props: any) {
const { isNew, ...rest } = props;
return (
<g
style={
isNew
? {
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
}
: undefined
}
>
<Rectangle {...rest} />
</g>
);
}
export function BarChart({ title, description, data }: BarChartProps) {
const { isNew } = useSeenIndices();
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<Card className="max-w-2xl mx-auto my-4">
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-[var(--muted-foreground)]" />
<CardTitle>{title}</CardTitle>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</CardContent>
</Card>
);
}
return (
<Card className="max-w-2xl mx-auto my-4 overflow-hidden">
{/* Scoped keyframe — no globals.css needed */}
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
<BarChart3 className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
</div>
<CardTitle>{title}</CardTitle>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="pt-2">
<ResponsiveContainer width="100%" height={280}>
<RechartsBarChart
data={data}
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={CHART_CONFIG.tooltipStyle}
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
shape={
((props: Record<string, unknown>) => (
<AnimatedBar
{...props}
isNew={isNew(props.index as number)}
/>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)) as any
}
>
{data.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,25 @@
/**
* CopilotKit brand chart palette — Plus Jakarta Sans / brand color system.
*/
export const CHART_COLORS = [
"#BEC2FF", // lilac-400
"#85ECCE", // mint-400
"#FFAC4D", // orange-400
"#FFF388", // yellow-400
"#189370", // mint-800
"#EEE6FE", // primary-100
"#FA5F67", // red-400
] as const;
export const CHART_CONFIG = {
tooltipStyle: {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "10px",
padding: "10px 14px",
color: "var(--foreground)",
fontSize: "13px",
fontFamily: "var(--font-body)",
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
},
};
@@ -0,0 +1,155 @@
import { z } from "zod";
import { CHART_COLORS } from "./config";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "../../ui/card";
export const PieChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type PieChartProps = z.infer<typeof PieChartProps>;
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
function DonutChart({
data,
size = 240,
strokeWidth = 40,
}: {
data: { label: string; value: number }[];
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
// Calculate each slice's arc length and starting position
let accumulated = 0;
const slices = data.map((item, index) => {
const val = Number(item.value) || 0;
const ratio = total > 0 ? val / total : 0;
const arc = ratio * circumference;
const startAt = accumulated;
accumulated += arc;
return {
...item,
arc,
gap: circumference - arc,
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
dashoffset: -startAt,
color: CHART_COLORS[index % CHART_COLORS.length],
};
});
return (
<svg
width="100%"
viewBox={`0 0 ${size} ${size}`}
className="block mx-auto"
style={{ maxWidth: size, transform: "scaleX(-1)" }}
>
{/* Background ring */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="var(--secondary)"
strokeWidth={strokeWidth}
/>
{/* Data slices */}
{slices.map((slice, i) => (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={slice.color}
strokeWidth={strokeWidth}
strokeDasharray={`${slice.arc} ${slice.gap}`}
strokeDashoffset={slice.dashoffset}
strokeLinecap="butt"
transform={`rotate(-90 ${center} ${center})`}
/>
))}
</svg>
);
}
export function PieChart({ title, description, data }: PieChartProps) {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<Card className="max-w-lg mx-auto my-4">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</CardContent>
</Card>
);
}
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
<Card className="max-w-lg mx-auto my-4 overflow-hidden">
<CardHeader className="pb-0">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<DonutChart data={data} />
{/* Legend */}
<div className="space-y-2 pt-4">
{data.map((item, index) => {
const val = Number(item.value) || 0;
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : 0;
return (
<div
key={index}
className="flex items-center gap-3 text-sm transition-opacity duration-300 ease-out"
style={{ opacity: 1 }}
>
<span
className="inline-block h-3 w-3 rounded-full shrink-0"
style={{
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span className="flex-1 text-[var(--foreground)] truncate">
{item.label}
</span>
<span className="text-[var(--muted-foreground)] tabular-nums">
{val.toLocaleString()}
</span>
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
{pct}%
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,177 @@
import { useState } from "react";
import { Card, CardContent } from "../ui/card";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Spinner } from "../ui/spinner";
import { Check, X, Clock, ChevronRight } from "lucide-react";
export interface TimeSlot {
date: string;
time: string;
duration?: string;
}
export interface MeetingTimePickerProps {
status: "inProgress" | "executing" | "complete";
respond?: (response: string) => void;
reasonForScheduling?: string;
meetingDuration?: number;
title?: string;
timeSlots?: TimeSlot[];
}
export function MeetingTimePicker({
status,
respond,
reasonForScheduling,
meetingDuration,
title = "Schedule a Meeting",
timeSlots = [
{ date: "Tomorrow", time: "2:00 PM", duration: "30 min" },
{ date: "Friday", time: "10:00 AM", duration: "30 min" },
{ date: "Next Monday", time: "3:00 PM", duration: "30 min" },
],
}: MeetingTimePickerProps) {
const displayTitle = reasonForScheduling || title;
const slots = meetingDuration
? timeSlots.map((slot) => ({ ...slot, duration: `${meetingDuration} min` }))
: timeSlots;
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
const [declined, setDeclined] = useState(false);
const handleSelectSlot = (slot: TimeSlot) => {
setSelectedSlot(slot);
respond?.(
`Meeting scheduled for ${slot.date} at ${slot.time}${slot.duration ? ` (${slot.duration})` : ""}.`,
);
};
const handleDecline = () => {
setDeclined(true);
respond?.(
"The user declined all proposed meeting times. Please suggest alternative times or ask for their availability.",
);
};
// Confirmed state
if (selectedSlot) {
return (
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-[#189370]">
<Check className="h-5 w-5 text-white" strokeWidth={3} />
</div>
<div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
Meeting Scheduled
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{selectedSlot.date} at {selectedSlot.time}
</p>
</div>
{selectedSlot.duration && (
<Badge variant="secondary">
<Clock className="h-3 w-3 mr-1" />
{selectedSlot.duration}
</Badge>
)}
</div>
</CardContent>
</Card>
);
}
// Declined state
if (declined) {
return (
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--secondary)]">
<X className="h-6 w-6 text-[var(--muted-foreground)]" />
</div>
<div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
No Time Selected
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
Looking for a better time that works for you
</p>
</div>
</div>
</CardContent>
</Card>
);
}
// Selection state
return (
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col items-center text-center mb-5">
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--accent)] mb-3">
<Clock className="h-6 w-6 text-[#BEC2FF]" />
</div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
{displayTitle}
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{status === "inProgress"
? "Finding available times..."
: "Pick a time that works for you"}
</p>
</div>
{status === "inProgress" && (
<div className="flex justify-center py-6">
<Spinner size="lg" />
</div>
)}
{status === "executing" && (
<div className="space-y-3">
{slots.map((slot, index) => (
<button
key={index}
onClick={() => handleSelectSlot(slot)}
className="group w-full px-6 py-5 rounded-[var(--radius)]
border border-[var(--border)]
hover:border-[var(--ring)] hover:bg-[var(--accent)]
transition-all duration-150 cursor-pointer
flex items-center gap-4"
>
<div className="flex-1 text-left">
<div className="font-semibold text-base text-[var(--foreground)]">
{slot.date}
</div>
<div className="text-sm text-[var(--muted-foreground)] mt-0.5">
{slot.time}
</div>
</div>
{slot.duration && (
<Badge
variant="secondary"
className="shrink-0 text-sm px-3 py-1"
>
{slot.duration}
</Badge>
)}
<ChevronRight className="h-4 w-4 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
<Button
variant="ghost"
size="sm"
className="w-full mt-1 text-xs text-[var(--muted-foreground)]"
onClick={handleDecline}
>
None of these work
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef } from "react";
import { Wrench, Check, ChevronDown } from "lucide-react";
import { Spinner } from "./ui/spinner";
interface ToolReasoningProps {
name: string;
args?: object | unknown;
status: string;
}
function formatValue(value: unknown): string {
if (Array.isArray(value)) return `[${value.length} items]`;
if (typeof value === "object" && value !== null)
return `{${Object.keys(value).length} keys}`;
if (typeof value === "string") return `"${value}"`;
return String(value);
}
export function ToolReasoning({ name, args, status }: ToolReasoningProps) {
const entries = args ? Object.entries(args) : [];
const detailsRef = useRef<HTMLDetailsElement>(null);
const isRunning = status === "executing" || status === "inProgress";
// Auto-open while executing, auto-close when complete
useEffect(() => {
if (!detailsRef.current) return;
detailsRef.current.open = isRunning;
}, [isRunning]);
const statusIcon = isRunning ? (
<Spinner size="sm" className="h-3 w-3" />
) : (
<Check className="h-3 w-3 text-emerald-500" />
);
return (
<div className="my-1.5">
{entries.length > 0 ? (
<details ref={detailsRef} open className="group">
<summary className="flex items-center gap-2 cursor-pointer list-none text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors">
{statusIcon}
<Wrench className="h-3 w-3" />
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
<ChevronDown className="h-3 w-3 ml-auto transition-transform group-open:rotate-180" />
</summary>
<div className="ml-5 mt-1.5 rounded-md bg-[var(--secondary)] px-3 py-2 space-y-1">
{entries.map(([key, value]) => (
<div
key={key}
className="flex gap-2 min-w-0 text-xs"
style={{ fontFamily: "var(--font-code)" }}
>
<span className="text-[var(--muted-foreground)] shrink-0">
{key}:
</span>
<span className="text-[var(--foreground)] truncate">
{formatValue(value)}
</span>
</div>
))}
</div>
</details>
) : (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
{statusIcon}
<Wrench className="h-3 w-3" />
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default:
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
secondary:
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
outline: "border-[var(--border)] text-[var(--foreground)]",
},
},
defaultVariants: {
variant: "secondary",
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,51 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
ghost:
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,85 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};
@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-5 w-5 shrink-0 rounded-md border border-[var(--border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--primary)] data-[state=checked]:text-[var(--primary-foreground)] data-[state=checked]:border-transparent cursor-pointer transition-colors",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-[var(--radius)] border border-[var(--input)] bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "../../lib/utils";
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-[var(--border)]",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
@@ -0,0 +1,24 @@
import { cn } from "../../lib/utils";
interface SpinnerProps {
className?: string;
size?: "sm" | "md" | "lg";
}
const sizeMap = {
sm: "h-4 w-4 border-2",
md: "h-6 w-6 border-2",
lg: "h-8 w-8 border-3",
};
export function Spinner({ className, size = "md" }: SpinnerProps) {
return (
<span
className={cn(
"inline-block rounded-full border-[var(--muted)] border-t-[var(--primary)] animate-spin",
sizeMap[size],
className,
)}
/>
);
}
@@ -0,0 +1,186 @@
/**
* Demonstration Catalog — Component Definitions
*
* Platform-agnostic definitions: component names, props (Zod), descriptions.
* This is the contract between the app and the AI agent. Agents receive these
* definitions as context so they know what components are available.
*
* Renderers (React, React Native, etc.) import these definitions and provide
* platform-specific implementations, type-checked against the Zod schemas.
*/
import { z } from "zod";
/**
* Dynamic string: accepts either a literal string or a data-model path binding
* like `{ path: "airline" }`. The GenericBinder resolves path bindings to the
* actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const demonstrationCatalogDefinitions = {
Title: {
description: "A heading. Use for section titles and page headers.",
props: z.object({
text: z.string(),
level: z.string().optional(),
}),
},
// Custom Row/Column: override the basic catalog's versions so we can
// honour `gap` (basic Row/Column from web_core ignores it). Children may
// be a literal-string array (flat trees) OR a structural template form
// `{ componentId, path }` so the GenericBinder expands per-row templates
// from the data model — required for fixed-schema flows like
// flight_schema.json (Row.children = { componentId, path: "/flights" }).
Row: {
description: "Horizontal layout container.",
props: z.object({
gap: z.number().optional(),
align: z.string().optional(),
justify: z.string().optional(),
// Union with { componentId, path } so GenericBinder treats this as
// STRUCTURAL and resolves template children from the data model.
children: z.union([
z.array(z.string()),
z.object({ componentId: z.string(), path: z.string() }),
]),
}),
},
Column: {
description: "Vertical layout container.",
props: z.object({
gap: z.number().optional(),
align: z.string().optional(),
// Same union as Row — required for template children support.
children: z.union([
z.array(z.string()),
z.object({ componentId: z.string(), path: z.string() }),
]),
}),
},
DashboardCard: {
description:
"A card container with title and optional subtitle. Has a 'child' slot for content (chart, metrics, etc). Use 'child' with a single component ID.",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
child: z.string().optional(),
}),
},
Metric: {
description:
"A key metric display with label, value, and optional trend indicator. Great for KPIs and stats.",
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).optional(),
trendValue: z.string().optional(),
}),
},
PieChart: {
description:
"A pie/donut chart. Provide data as array of {label, value, color} objects.",
props: z.object({
data: z.array(
z.object({
label: z.string(),
value: z.number(),
color: z.string().optional(),
}),
),
innerRadius: z.number().optional(),
}),
},
BarChart: {
description:
"A bar chart. Provide data as array of {label, value} objects.",
props: z.object({
data: z.array(z.object({ label: z.string(), value: z.number() })),
color: z.string().optional(),
}),
},
Badge: {
description:
"A small status badge/tag. Use for labels, statuses, categories.",
props: z.object({
text: z.string(),
variant: z
.enum(["success", "warning", "error", "info", "neutral"])
.optional(),
}),
},
DataTable: {
description: "A data table with columns and rows.",
props: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
rows: z.array(z.record(z.any())),
}),
},
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. 'action' is dispatched on click.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
FlightCard: {
description:
"A rich flight result card. Displays airline, flight number, route, times, duration, status, and price. Use inside a Row for side-by-side layout.",
props: z.object({
airline: DynString,
airlineLogo: DynString,
flightNumber: DynString,
origin: DynString,
destination: DynString,
date: DynString,
departureTime: DynString,
arrivalTime: DynString,
duration: DynString,
status: DynString,
statusColor: DynString.optional(),
price: DynString,
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
};
/** Type helper for renderers */
export type DemonstrationCatalogDefinitions =
typeof demonstrationCatalogDefinitions;
@@ -0,0 +1,606 @@
/**
* A2UI Catalog — React Renderers
*
* Each renderer maps a component name from definitions.ts to a React
* implementation. Props are type-checked against the Zod schemas.
*
* To add a component: define its schema in definitions.ts, then add a
* renderer here. See README.md "Adding a custom component" for details.
*
* The assembled catalog is registered in layout.tsx via
* <CopilotKit a2ui={{ catalog: demonstrationCatalog }}>.
*/
"use client";
import React, { useState } from "react";
import type { JSX } from "react";
import {
PieChart as RechartsPie,
Pie,
Cell,
ResponsiveContainer,
BarChart as RechartsBar,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
} from "recharts";
import { createCatalog } from "@copilotkit/a2ui-renderer";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import { demonstrationCatalogDefinitions } from "./definitions";
import type { DemonstrationCatalogDefinitions } from "./definitions";
// ─── Theme-aware colors ─────────────────────────────────────────────
const c = {
card: "var(--card)",
cardFg: "var(--card-foreground)",
border: "var(--border)",
muted: "var(--muted-foreground)",
divider: "color-mix(in srgb, var(--border) 50%, var(--card))",
shadow: "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)",
btnBg: "color-mix(in srgb, var(--muted) 40%, var(--card))",
btnDoneBg: "color-mix(in srgb, #22c55e 10%, var(--card))",
};
function ActionButton({
label,
doneLabel,
action,
children: child,
}: {
label: string;
doneLabel: string;
action: any;
children?: React.ReactNode;
}) {
const [done, setDone] = useState(false);
return (
<button
disabled={done}
style={{
width: "100%",
padding: "10px 16px",
borderRadius: "10px",
border: done ? "1px solid #bbf7d0" : `1px solid ${c.border}`,
background: done ? c.btnDoneBg : c.btnBg,
color: done ? "#059669" : c.cardFg,
fontSize: "0.85rem",
fontWeight: 500,
cursor: done ? "default" : "pointer",
transition: "all 0.2s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
onClick={() => {
if (!done) {
action?.();
setDone(true);
}
}}
>
{done && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#059669"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{done ? doneLabel : (child ?? label)}
</button>
);
}
// ─── Renderers (type-checked against schema definitions) ────────────
const demonstrationCatalogRenderers: CatalogRenderers<DemonstrationCatalogDefinitions> =
{
Title: ({ props }) => {
const Tag = (
props.level === "h1" ? "h1" : props.level === "h3" ? "h3" : "h2"
) as keyof JSX.IntrinsicElements;
const sizes: Record<string, string> = {
h1: "1.75rem",
h2: "1.25rem",
h3: "1rem",
};
return (
<Tag
style={{
margin: 0,
fontWeight: 600,
fontSize: sizes[props.level ?? "h2"],
color: c.cardFg,
letterSpacing: "-0.01em",
}}
>
{props.text}
</Tag>
);
},
Row: ({ props, children }) => {
const justifyMap: Record<string, string> = {
start: "flex-start",
center: "center",
end: "flex-end",
spaceBetween: "space-between",
};
const items = Array.isArray(props.children) ? props.children : [];
return (
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${props.gap ?? 16}px`,
alignItems: props.align ?? "stretch",
justifyContent:
justifyMap[props.justify ?? "start"] ?? "flex-start",
flexWrap: "wrap",
width: "100%",
}}
>
{items.map((item: any, i: number) => {
if (typeof item === "string")
return (
<div
key={`${item}-${i}`}
style={{ flex: "1 1 0", minWidth: 0 }}
>
{children(item)}
</div>
);
if (item && typeof item === "object" && "id" in item)
return (
<div
key={`${item.id}-${i}`}
style={{ flex: "1 1 0", minWidth: 0 }}
>
{(children as any)(item.id, item.basePath)}
</div>
);
return null;
})}
</div>
);
},
Column: ({ props, children }) => {
const items = Array.isArray(props.children) ? props.children : [];
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${props.gap ?? 12}px`,
width: "100%",
}}
>
{items.map((item: any, i: number) => {
if (typeof item === "string")
return (
<React.Fragment key={`${item}-${i}`}>
{children(item)}
</React.Fragment>
);
if (item && typeof item === "object" && "id" in item)
return (
<React.Fragment key={`${item.id}-${i}`}>
{(children as any)(item.id, item.basePath)}
</React.Fragment>
);
return null;
})}
</div>
);
},
DashboardCard: ({ props, children }) => (
<div
style={{
background: c.card,
borderRadius: "12px",
border: `1px solid ${c.border}`,
padding: "20px",
boxShadow: c.shadow,
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: c.cardFg }}>
{props.title}
</div>
{props.subtitle && (
<div
style={{
fontSize: "0.75rem",
color: c.muted,
marginTop: "2px",
}}
>
{props.subtitle}
</div>
)}
</div>
{props.child && children(props.child)}
</div>
),
Metric: ({ props }) => {
const trendColors: Record<string, string> = {
up: "#059669",
down: "#dc2626",
neutral: c.muted,
};
const trendIcons: Record<string, string> = {
up: "↑",
down: "↓",
neutral: "→",
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<span
style={{
fontSize: "0.75rem",
color: c.muted,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{props.label}
</span>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px" }}>
<span
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: c.cardFg,
letterSpacing: "-0.02em",
}}
>
{props.value}
</span>
{props.trend && props.trendValue && (
<span
style={{
fontSize: "0.8rem",
fontWeight: 500,
color: trendColors[props.trend] ?? c.muted,
}}
>
{trendIcons[props.trend]} {props.trendValue}
</span>
)}
</div>
</div>
);
},
PieChart: ({ props }) => {
const COLORS = [
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#f59e0b",
"#10b981",
"#6366f1",
];
const data = props.data ?? [];
return (
<div style={{ width: "100%", height: 200 }}>
<ResponsiveContainer>
<RechartsPie>
<Pie
data={data}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={props.innerRadius ?? 40}
outerRadius={80}
paddingAngle={2}
>
{data.map((entry: any, i: number) => (
<Cell
key={i}
fill={entry.color ?? COLORS[i % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
</RechartsPie>
</ResponsiveContainer>
</div>
);
},
BarChart: ({ props }) => {
const data = props.data ?? [];
return (
<div style={{ width: "100%", height: 200 }}>
<ResponsiveContainer>
<RechartsBar data={data}>
<CartesianGrid strokeDasharray="3 3" stroke={c.divider} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: c.muted }} />
<YAxis tick={{ fontSize: 11, fill: c.muted }} />
<Tooltip />
<Bar
dataKey="value"
fill={props.color ?? "#3b82f6"}
radius={[4, 4, 0, 0]}
/>
</RechartsBar>
</ResponsiveContainer>
</div>
);
},
Badge: ({ props }) => {
const variants: Record<string, { bg: string; color: string }> = {
success: { bg: "#dcfce7", color: "#166534" },
warning: { bg: "#fef3c7", color: "#92400e" },
error: { bg: "#fee2e2", color: "#991b1b" },
info: { bg: "#dbeafe", color: "#1e40af" },
neutral: { bg: "var(--muted)", color: c.cardFg },
};
const v = variants[props.variant ?? "neutral"] ?? variants.neutral;
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "9999px",
fontSize: "0.7rem",
fontWeight: 500,
background: v.bg,
color: v.color,
}}
>
{props.text}
</span>
);
},
DataTable: ({ props }) => {
const cols = props.columns ?? [];
const rows = props.rows ?? [];
return (
<div style={{ overflowX: "auto", width: "100%" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
{cols.map((col: any) => (
<th
key={col.key}
style={{
textAlign: "left",
padding: "8px 12px",
borderBottom: `2px solid ${c.border}`,
color: c.muted,
fontWeight: 600,
fontSize: "0.7rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row: any, i: number) => (
<tr key={i} style={{ borderBottom: `1px solid ${c.divider}` }}>
{cols.map((col: any) => (
<td
key={col.key}
style={{ padding: "8px 12px", color: c.cardFg }}
>
{String(row[col.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
},
Button: ({ props, children }) => {
return (
<ActionButton label="Click" doneLabel="Done" action={props.action}>
{props.child ? children(props.child) : null}
</ActionButton>
);
},
FlightCard: ({ props: rawProps }) => {
// The binder resolves path bindings to strings at runtime.
const props = rawProps as Record<string, any>;
const statusColors: Record<string, string> = {
"On Time": "#22c55e",
Delayed: "#eab308",
Cancelled: "#ef4444",
};
const dotColor =
props.statusColor ?? statusColors[props.status] ?? "#22c55e";
return (
<div
style={{
border: `1px solid ${c.border}`,
borderRadius: "16px",
padding: "20px",
background: c.card,
color: c.cardFg,
minWidth: 260,
maxWidth: 340,
flex: "1 1 260px",
display: "flex",
flexDirection: "column",
gap: "12px",
boxShadow: c.shadow,
}}
>
{/* Header: airline + price */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<img
src={props.airlineLogo}
alt={props.airline}
style={{
width: 28,
height: 28,
borderRadius: "50%",
objectFit: "contain",
}}
/>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
{props.airline}
</span>
</div>
<span style={{ fontWeight: 700, fontSize: "1.15rem" }}>
{props.price}
</span>
</div>
{/* Meta */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8rem",
color: c.muted,
}}
>
<span>{props.flightNumber}</span>
<span>{props.date}</span>
</div>
<hr
style={{
border: "none",
borderTop: `1px solid ${c.divider}`,
margin: 0,
}}
/>
{/* Times */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
{props.departureTime}
</span>
<span style={{ fontSize: "0.75rem", color: c.muted }}>
{props.duration}
</span>
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
{props.arrivalTime}
</span>
</div>
{/* Route */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.95rem",
fontWeight: 600,
}}
>
<span>{props.origin}</span>
<span style={{ color: c.muted }}></span>
<span>{props.destination}</span>
</div>
<div
style={{
marginTop: "auto",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<hr
style={{
border: "none",
borderTop: `1px solid ${c.divider}`,
margin: 0,
}}
/>
{/* Status */}
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: dotColor,
display: "inline-block",
}}
/>
<span style={{ fontSize: "0.8rem", color: c.muted }}>
{props.status}
</span>
</div>
<ActionButton
label="Select"
doneLabel="Selected"
action={props.action}
/>
</div>
</div>
);
},
};
// ─── Assembled Catalog ───────────────────────────────────────────────
export const demonstrationCatalog = createCatalog(
demonstrationCatalogDefinitions,
demonstrationCatalogRenderers,
{
catalogId: "copilotkit://app-dashboard-catalog",
// Required: merges the basic A2UI primitives (Row, Column, Text, Card,
// Button, …) into this catalog so structural-children expansion works
// for templates like flight_schema.json's
// `Row { children: { componentId: "flight-card", path: "/flights" } }`.
// Both sibling working demos (a2ui-fixed-schema, declarative-gen-ui)
// already set this — beautiful-chat was the outlier.
includeBasicCatalog: true,
},
);
@@ -0,0 +1,23 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { ExampleLayout } from "./components/example-layout";
import { ExampleCanvas } from "./components/example-canvas";
import { useGenerativeUIExamples, useExampleSuggestions } from "./hooks";
export function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
return (
<ExampleLayout
chatContent={
<CopilotChat
attachments={{ enabled: true }}
input={{ disclaimer: () => null, className: "pb-6" }}
/>
}
appContent={<ExampleCanvas />}
/>
);
}
@@ -0,0 +1,3 @@
export * from "./use-example-suggestions";
export * from "./use-generative-ui-examples";
export * from "./use-theme";
@@ -0,0 +1,69 @@
/**
* Suggestion pills shown in the chat UI. Each suggestion triggers a specific
* demo feature when clicked.
*
* Ordered from most constrained (fixed UI) to most open (freeform UI).
*
* Showcase mode (showcase.json) controls which pills are visually highlighted.
* Highlight styling: globals.css (.a2ui-highlight, .opengenui-highlight)
* A2UI agent tools: agent/src/a2ui_fixed_schema.py, a2ui_dynamic_schema.py
* A2UI catalog: src/app/declarative-generative-ui/
*/
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
import showcaseConfig from "../showcase.json";
const showcase = showcaseConfig.showcase;
export const useExampleSuggestions = () => {
useConfigureSuggestions({
suggestions: [
{
title: "Pie Chart (Controlled Generative UI)",
message:
"Show me a pie chart of our revenue distribution by category. Use the query_data tool to fetch the data first, then render it with the pieChart component.",
},
{
title: "Bar Chart (Controlled Generative UI)",
message:
"Show me a bar chart of our expenses by category. Use the query_data tool to fetch the data first, then render it with the barChart component.",
},
{
title: "Schedule Meeting (Human In The Loop)",
message:
"I'd like to schedule a 30-minute meeting to learn about CopilotKit. Please use the scheduleTime tool to let me pick a time.",
},
{
title: "Search Flights (A2UI Fixed Schema)",
message: "Find flights from SFO to JFK for next Tuesday.",
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
},
{
title: "Sales Dashboard (A2UI Dynamic)",
message:
"First use the query_data tool to fetch the financial sales data, then using A2UI, show me a sales dashboard with total revenue, new customers, and conversion rate metrics. Include a pie chart of revenue by category and a bar chart of monthly sales.",
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
},
{
title: "Excalidraw Diagram (MCP App)",
message:
"Use Excalidraw to create a simple network diagram showing a router connected to two switches, each connected to two computers.",
},
{
title: "Calculator App (Open Generative UI)",
message:
"Using the generateSandboxedUi tool, build a modern calculator with standard buttons plus labeled metric shortcut buttons that insert their values into the display when clicked. Use sample company data.",
className: showcase === "opengenui" ? "opengenui-highlight" : undefined,
},
{
title: "Toggle Theme (Frontend Tools)",
message: "Toggle the app theme using the toggleTheme tool.",
},
{
title: "Task Manager (Shared State)",
message:
"Enable app mode and add three todos about learning CopilotKit: one about reading the docs, one about building a prototype, and one about exploring agent state.",
},
],
available: "always",
});
};
@@ -0,0 +1,85 @@
import { z } from "zod";
import { useTheme } from "./use-theme";
import {
useComponent,
useFrontendTool,
useHumanInTheLoop,
useDefaultRenderTool,
} from "@copilotkit/react-core/v2";
import {
PieChart,
PieChartProps,
} from "../components/generative-ui/charts/pie-chart";
import {
BarChart,
BarChartProps,
} from "../components/generative-ui/charts/bar-chart";
import { MeetingTimePicker } from "../components/generative-ui/meeting-time-picker";
import { ToolReasoning } from "../components/tool-rendering";
export const useGenerativeUIExamples = () => {
const { setTheme } = useTheme();
// Human-in-the-Loop (frontend tool requiring user decision)
useHumanInTheLoop({
name: "scheduleTime",
description: "Use human-in-the-loop to schedule a meeting with the user.",
parameters: z.object({
reasonForScheduling: z
.string()
.describe("Reason for scheduling, very brief - 5 words."),
meetingDuration: z
.number()
.describe("Duration of the meeting in minutes"),
}),
render: ({ respond, status, args }) => {
return <MeetingTimePicker status={status} respond={respond} {...args} />;
},
});
// Controlled Generative UI (frontend-defined chart components)
useComponent({
name: "pieChart",
description: "Controlled Generative UI that displays data as a pie chart.",
parameters: PieChartProps,
render: PieChart,
});
useComponent({
name: "barChart",
description: "Controlled Generative UI that displays data as a bar chart.",
parameters: BarChartProps,
render: BarChart,
});
// Default Tool Rendering (backend tool UI)
const ignoredTools = [
"render_a2ui", // Rendered by A2UI streaming, not as a tool card
"generate_a2ui", // Legacy: rendered by A2UI, not as a tool card
"log_a2ui_event", // Internal A2UI event tracker
];
useDefaultRenderTool({
render: ({ name, status, parameters }) => {
if (ignoredTools.includes(name)) return <></>;
return <ToolReasoning name={name} status={status} args={parameters} />;
},
});
// Frontend Tools (direct frontend state manipulation).
// No deps array needed — the handler reads `document` directly and
// calls a stable setter. Including [theme, setTheme] in deps caused
// the hook to re-register every time the theme flipped, which could
// race with an in-flight tool result from the runtime and surface
// as a renderer-level error during multi-turn beautiful-chat probes.
useFrontendTool({
name: "toggleTheme",
description: "Frontend tool for toggling the theme of the app.",
parameters: z.object({}),
handler: async () => {
const isDark = document.documentElement.classList.contains("dark");
setTheme(isDark ? "light" : "dark");
},
});
};
@@ -0,0 +1,43 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
const ThemeContext = createContext<{
theme: Theme;
setTheme: (t: Theme) => void;
}>({
theme: "system",
setTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
root.classList.remove("light", "dark");
root.classList.add(mq.matches ? "dark" : "light");
};
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}
root.classList.add(theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
@@ -0,0 +1,162 @@
:root {
--n-100: #ffffff;
--n-99: #fcfcfc;
--n-98: #f9f9f9;
--n-95: #f1f1f1;
--n-90: #e2e2e2;
--n-80: #c6c6c6;
--n-70: #ababab;
--n-60: #919191;
--n-50: #777777;
--n-40: #5e5e5e;
--n-35: #525252;
--n-30: #474747;
--n-25: #3b3b3b;
--n-20: #303030;
--n-15: #262626;
--n-10: #1b1b1b;
--n-5: #111111;
--n-0: #000000;
--p-100: #ffffff;
--p-99: #fffbff;
--p-98: #fcf8ff;
--p-95: #f2efff;
--p-90: #e1e0ff;
--p-80: #c0c1ff;
--p-70: #a0a3ff;
--p-60: #8487ea;
--p-50: #6a6dcd;
--p-40: #5154b3;
--p-35: #4447a6;
--p-30: #383b99;
--p-25: #2c2e8d;
--p-20: #202182;
--p-15: #131178;
--p-10: #06006c;
--p-5: #03004d;
--p-0: #000000;
--s-100: #ffffff;
--s-99: #fffbff;
--s-98: #fcf8ff;
--s-95: #f2efff;
--s-90: #e2e0f9;
--s-80: #c6c4dd;
--s-70: #aaa9c1;
--s-60: #8f8fa5;
--s-50: #75758b;
--s-40: #5d5c72;
--s-35: #515165;
--s-30: #454559;
--s-25: #393a4d;
--s-20: #2e2f42;
--s-15: #242437;
--s-10: #191a2c;
--s-5: #0f0f21;
--s-0: #000000;
--t-100: #ffffff;
--t-99: #fffbff;
--t-98: #fff8f9;
--t-95: #ffecf4;
--t-90: #ffd8ec;
--t-80: #e9b9d3;
--t-70: #cc9eb8;
--t-60: #af849d;
--t-50: #946b83;
--t-40: #79536a;
--t-35: #6c475d;
--t-30: #5f3c51;
--t-25: #523146;
--t-20: #46263a;
--t-15: #3a1b2f;
--t-10: #2e1125;
--t-5: #22071a;
--t-0: #000000;
--nv-100: #ffffff;
--nv-99: #fffbff;
--nv-98: #fcf8ff;
--nv-95: #f2effa;
--nv-90: #e4e1ec;
--nv-80: #c8c5d0;
--nv-70: #acaab4;
--nv-60: #918f9a;
--nv-50: #777680;
--nv-40: #5e5d67;
--nv-35: #52515b;
--nv-30: #46464f;
--nv-25: #3b3b43;
--nv-20: #303038;
--nv-15: #25252d;
--nv-10: #1b1b23;
--nv-5: #101018;
--nv-0: #000000;
--e-100: #ffffff;
--e-99: #fffbff;
--e-98: #fff8f7;
--e-95: #ffedea;
--e-90: #ffdad6;
--e-80: #ffb4ab;
--e-70: #ff897d;
--e-60: #ff5449;
--e-50: #de3730;
--e-40: #ba1a1a;
--e-35: #a80710;
--e-30: #93000a;
--e-25: #7e0007;
--e-20: #690005;
--e-15: #540003;
--e-10: #410002;
--e-5: #2d0001;
--e-0: #000000;
--primary: #137fec;
--text-color: #fff;
--background-light: #f6f7f8;
--background-dark: #101922;
--border-color: oklch(
from var(--background-light) l c h / calc(alpha * 0.15)
);
--elevated-background-light: oklch(
from var(--background-light) l c h / calc(alpha * 0.05)
);
--bb-grid-size: 4px;
--bb-grid-size-2: calc(var(--bb-grid-size) * 2);
--bb-grid-size-3: calc(var(--bb-grid-size) * 3);
--bb-grid-size-4: calc(var(--bb-grid-size) * 4);
--bb-grid-size-5: calc(var(--bb-grid-size) * 5);
--bb-grid-size-6: calc(var(--bb-grid-size) * 6);
--bb-grid-size-7: calc(var(--bb-grid-size) * 7);
--bb-grid-size-8: calc(var(--bb-grid-size) * 8);
--bb-grid-size-9: calc(var(--bb-grid-size) * 9);
--bb-grid-size-10: calc(var(--bb-grid-size) * 10);
--bb-grid-size-11: calc(var(--bb-grid-size) * 11);
--bb-grid-size-12: calc(var(--bb-grid-size) * 12);
--bb-grid-size-13: calc(var(--bb-grid-size) * 13);
--bb-grid-size-14: calc(var(--bb-grid-size) * 14);
--bb-grid-size-15: calc(var(--bb-grid-size) * 15);
--bb-grid-size-16: calc(var(--bb-grid-size) * 16);
}
* {
box-sizing: border-box;
}
html,
body {
--font-family: "Google Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-family-flex:
"Google Sans Flex", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-family-mono:
"Google Sans Code", "Helvetica Neue", Helvetica, Arial, sans-serif;
background: var(--background-light);
font-family: var(--font-family);
margin: 0;
padding: 0;
width: 100svw;
height: 100svh;
}
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,51 @@
"use client";
/**
* Beautiful Chat — the flagship CopilotKit showcase cell, ported verbatim
* from the 4084 reference clone. The 4084 version lived as its own Next.js
* frontend at `demos/beautiful-chat/frontend/` with a full `src/components`
* tree + A2UI catalog. Here the same tree is colocated under the cell and
* re-wired with relative imports.
*
* Providers: layout-level `CopilotKit` + `ThemeProvider` wrappers from the
* original 4084 root layout are applied here instead, because the unified
* 4085 shell does not give each cell its own layout.tsx.
*
* Runtime: this cell uses its own dedicated runtime endpoint
* (`/api/copilotkit-beautiful-chat`) so it can enable `openGenerativeUI`,
* `a2ui` with `injectA2UITool: false`, and `mcpApps` simultaneously — the
* same combined-runtime shape the canonical starter uses — without bleeding
* those global flags into other cells sharing the main `/api/copilotkit`
* endpoint. The backend graph is `beautiful_chat` (src/agents/beautiful_chat.py).
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { ThemeProvider } from "./hooks/use-theme";
import { demonstrationCatalog } from "./declarative-generative-ui/renderers";
import { HomePage } from "./home-page";
export default function BeautifulChatPage() {
return (
<ThemeProvider>
<CopilotKit
runtimeUrl="/api/copilotkit-beautiful-chat"
agent="beautiful-chat"
a2ui={{ catalog: demonstrationCatalog }}
openGenerativeUI={{}}
/*
* `useSingleEndpoint` defaults to true (the single-POST-endpoint
* protocol). The canonical reference sets it to false to use the
* v2 multi-endpoint protocol (GET /info + POST /agent/{name}/connect),
* which requires a Hono-based endpoint via `createCopilotEndpoint`.
* The 4085 showcase uses `copilotRuntimeNextJSAppRouterEndpoint`
* (single-endpoint), which matches the other 4085 cells — so we
* use its default behavior here. Functionally equivalent for this demo.
*/
>
<HomePage />
</CopilotKit>
</ThemeProvider>
);
}
@@ -0,0 +1,3 @@
{
"showcase": "default"
}
@@ -0,0 +1,81 @@
# Chat Customization (CSS)
## What This Demo Shows
How far you can push `CopilotChat` with CSS alone — no slot overrides, no
component swaps, no React. The default look is rounded, system-sans, and
minimal-light. This demo replaces it with **HALCYON**, a warm-paper
editorial brand: cream parchment surface, sharp 90° corners, copper-ember
accents, an italic display serif for big headings, a Fraunces serif voice
for the assistant, and JetBrains Mono dispatch lines for the user.
The point: a team can take CopilotChat off the shelf and skin it to match
their own brand without ever opening a component file.
## How it works
Two layers do the work:
1. **v2 token overrides on `[data-copilotkit]`**`--background`,
`--foreground`, `--primary`, `--muted`, `--border`, `--ring`, `--radius`,
etc. Recolors every Tailwind utility (`cpk:bg-muted`,
`cpk:text-foreground`, …) the runtime renders.
2. **Class-targeted styling**`.copilotKitChat`, `.copilotKitMessages`,
`.copilotKitMessage.copilotKitUserMessage`,
`.copilotKitMessage.copilotKitAssistantMessage`, `.copilotKitInput`, the
welcome screen, suggestions, scrollbar.
Every selector is namespaced under `.chat-css-demo-scope`, so the theme
cannot leak into the rest of the showcase.
## How to Interact
Type any prompt and watch the conversation render in the HALCYON voice:
- `"Say hi"`
- `"Write a one-paragraph product memo about quarterly OKRs"`
- `"Show me a Python snippet for retry with exponential backoff"`
- `"Quote a famous business strategist on focus"`
You'll see:
- The user line render as a mono CLI dispatch with an ember `→` marker
- The assistant respond in serif body type with editorial spacing, an
ember left rule, and a dark code-card for code blocks
- The composer pill flatten to a sharp card with an ember focus ring and
a square copper send button
## Aesthetic Notes
- **Surface** — warm parchment (`#F4EFE6`) with a single ambient ember glow
in the top-left and a barely-perceptible paper-grain noise via inline
SVG
- **Masthead** — a centered mono label pinned just under the top edge of
the chat surface (`CopilotChat · Customized with CSS`)
- **Typography** — Instrument Serif (display, italic), Fraunces (assistant
body), Inter Tight (UI), JetBrains Mono (user dispatch + metadata +
suggestions)
- **Accent** — deep copper ember (`#C44A1F`), used only on the user prompt
marker, the assistant left rule, the send button, and focus rings —
sparingly, so it actually reads as signal
- **Geometry** — sharp 90° corners everywhere (radius is overridden to
`0px`), opposite of the default rounded pills
## Technical Details
- `<CopilotKit>` wires `runtimeUrl="/api/copilotkit"` and
`agent="chat-customization-css"` (backed by `graph` in
`src/agents/main.py`)
- `<CopilotChat>` is wrapped in `<div className="chat-css-demo-scope">`;
the theme is applied by `import "./theme.css"` at the top of the page
- `theme.css` first overrides the v2 token variables on `[data-copilotkit]`
(so Tailwind utilities recolor automatically), then layers
class-targeted rules on top for the editorial details that CSS
variables alone can't express
- Fonts load from Google Fonts via `@import` at the top of `theme.css`
so the demo is self-contained — copy the file into another project and
the theme works end-to-end
- Reach for slots (see `chat-slots`) when you need to change _what_ a
piece renders, not just how it looks; reach for CSS — like this demo —
when the default structure is fine and you only need a different
visual identity
@@ -0,0 +1,30 @@
"use client";
// Chat Customization (CSS) — every visual choice in this demo lives in
// theme.css and is scoped to the `.chat-css-demo-scope` wrapper. The page
// intentionally stays minimal so the contrast against the default look
// comes entirely from the stylesheet.
//
// https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
import React from "react";
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
// @region[theme-css-import]
import "./theme.css";
// @endregion[theme-css-import]
export default function ChatCustomizationCssDemo() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="chat-customization-css">
<div className="flex justify-center items-center h-screen w-full bg-white p-6">
<div className="chat-css-demo-scope h-full w-full max-w-4xl">
<CopilotChat
agentId="chat-customization-css"
className="h-full"
attachments={{ enabled: true }}
/>
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,628 @@
/* HALCYON — a warm-paper editorial theme for CopilotChat.
*
* The point of this demo is to show how far a single stylesheet can take
* CopilotChat away from the default look without touching components or
* slots. Every selector is namespaced under `.chat-css-demo-scope` so this
* theme cannot leak into the rest of the showcase.
*
* Two layers do the work:
* 1. v2 token overrides on `[data-copilotkit]` recolor every Tailwind
* utility (cpk:bg-muted, cpk:text-foreground, cpk:border, …) the
* runtime relies on — see @copilotkit/react-core/v2/styles.css.
* 2. Targeted class rules on `.copilotKitChat`, `.copilotKitMessage*`,
* and `.copilotKitInput` add the editorial details: parchment grain,
* corner brackets, serif voice, mono dispatch, ember accents.
*
* Class-name reference:
* https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
*/
/* @region[google-fonts] */
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Instrument+Serif:ital@0;1&family=Inter+Tight:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap");
/* @endregion[google-fonts] */
/* @region[design-tokens] */
/* 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);
}
/* @endregion[design-tokens] */
/* @region[v2-token-overrides] */
/* CopilotKit v2 reads these on the [data-copilotkit] root inside the chat.
* Re-pointing them under our scope retints every Tailwind utility the
* runtime renders (user message bubble, prose, borders, focus rings, …)
* without us having to touch any individual class. */
.chat-css-demo-scope [data-copilotkit] {
--background: var(--halcyon-paper);
--foreground: var(--halcyon-ink);
--card: var(--halcyon-card);
--card-foreground: var(--halcyon-ink);
--popover: var(--halcyon-paper-elevated);
--popover-foreground: var(--halcyon-ink);
--primary: var(--halcyon-ember);
--primary-foreground: var(--halcyon-paper-elevated);
--secondary: var(--halcyon-paper-soft);
--secondary-foreground: var(--halcyon-ink);
--muted: var(--halcyon-paper-soft);
--muted-foreground: var(--halcyon-ink-mute);
--accent: var(--halcyon-ember-soft);
--accent-foreground: var(--halcyon-ember);
--destructive: #b3361b;
--destructive-foreground: var(--halcyon-paper-elevated);
--border: var(--halcyon-rule);
--input: var(--halcyon-rule);
--ring: var(--halcyon-ember);
--radius: 0px;
}
/* @endregion[v2-token-overrides] */
/* @region[chat-shell] */
/* The chat surface — warm parchment with a single ambient ember glow,
* a barely-perceptible paper grain via inline SVG noise, and architectural
* corner brackets. Sharp 90° corners are deliberate; the default look is
* rounded, so squaring everything off is the fastest visual signal that
* "this is a different brand". */
.chat-css-demo-scope .copilotKitChat {
font-family: var(--halcyon-sans);
color: var(--halcyon-ink);
background-color: var(--halcyon-paper);
background-image:
radial-gradient(
900px 460px at 0% -10%,
rgba(228, 95, 43, 0.14),
transparent 62%
),
radial-gradient(
720px 380px at 100% 110%,
rgba(152, 121, 74, 0.08),
transparent 65%
),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.09 0 0 0 0 0.07 0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
border: 1px solid var(--halcyon-rule);
border-radius: 0;
box-shadow: var(--halcyon-shadow-soft);
position: relative;
overflow: hidden;
}
/* The masthead label — a small mono bar pinned to the top of the surface,
* playing against the editorial serif voice. Lives on ::before so it
* tracks the chat root and shows in every state (welcome, mid-thread,
* empty after clear). */
.chat-css-demo-scope .copilotKitChat::before {
content: "CopilotChat · Customized with CSS";
position: absolute;
top: 18px;
left: 0;
right: 0;
text-align: center;
font-family: var(--halcyon-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--halcyon-ink-mute);
white-space: nowrap;
pointer-events: none;
z-index: 3;
}
/* @endregion[chat-shell] */
/* @region[welcome] */
/* The welcome screen — the page-one impression. The default heading is
* sans-serif and tidy; we replace it with a large italic display serif
* that wraps the question like a magazine cover line. */
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] {
padding-top: 4rem;
}
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1 {
font-family: var(--halcyon-display);
font-size: clamp(2.4rem, 5vw, 4rem);
font-weight: 400;
font-style: italic;
color: var(--halcyon-ink);
letter-spacing: -0.02em;
line-height: 1.05;
text-align: center;
margin: 0 auto 0.6rem;
max-width: 22ch;
position: relative;
}
/* A small mono eyebrow above the heading. */
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
content: "CopilotKit";
display: block;
font-family: var(--halcyon-mono);
font-size: 11px;
font-style: normal;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--halcyon-ember);
margin-bottom: 1.2rem;
}
/* A short rule under the heading as a visual settle point. */
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::after {
content: "";
display: block;
width: 36px;
height: 1px;
background: var(--halcyon-rule-strong);
margin: 1.4rem auto 0;
}
/* @endregion[welcome] */
/* @region[messages-container] */
.chat-css-demo-scope .copilotKitMessages {
font-family: var(--halcyon-sans);
background: transparent;
color: var(--halcyon-ink);
padding: 5rem 0 2rem;
line-height: 1.6;
}
/* @endregion[messages-container] */
/* @region[user-message] */
/* 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;
}
/* @endregion[user-message] */
/* @region[assistant-message] */
/* Assistant message — editorial Fraunces serif, no bubble, just generous
* paragraphs offset by a thin ember rule on the left. Reads like the
* voice of a publication, not a chatbot. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage {
background: transparent;
color: var(--halcyon-ink);
font-family: var(--halcyon-serif);
font-size: 1.0625rem;
font-weight: 400;
padding: 4px 0 4px 22px;
border: none;
border-radius: 0;
margin-right: auto;
margin-bottom: 1.25rem;
max-width: 78ch;
position: relative;
}
/* The editorial left rule. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage::before {
content: "";
position: absolute;
top: 0.45em;
bottom: 0.45em;
left: 0;
width: 1px;
background: var(--halcyon-ember);
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose,
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose p {
font-family: var(--halcyon-serif);
font-size: inherit;
color: inherit;
line-height: 1.7;
font-feature-settings: "ss01", "ss02", "ss03", "kern";
margin: 0 0 0.85em;
}
/* Headings inside assistant content swap to the display serif so a long
* answer reads like a structured article. */
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
:is(h1, h2, h3, h4) {
font-family: var(--halcyon-display);
font-style: italic;
font-weight: 400;
letter-spacing: -0.015em;
color: var(--halcyon-ink);
margin: 1em 0 0.4em;
line-height: 1.15;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h1 {
font-size: 2rem;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h2 {
font-size: 1.55rem;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h3 {
font-size: 1.25rem;
}
/* Lists — looser, with serif numerals. */
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
:is(ul, ol) {
margin: 0.5em 0 1em;
padding-left: 1.4em;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose li {
margin: 0.25em 0;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
ol
> li::marker {
color: var(--halcyon-ember);
font-feature-settings: "tnum";
font-weight: 500;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
ul
> li::marker {
color: var(--halcyon-ember);
}
/* Blockquote — pull-quote treatment in italic display serif. */
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
blockquote {
border-left: 0;
margin: 1.2em 0;
padding: 0 0 0 1em;
font-family: var(--halcyon-display);
font-style: italic;
font-size: 1.25em;
color: var(--halcyon-ink-soft);
position: relative;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
blockquote::before {
content: "“";
position: absolute;
left: -0.05em;
top: -0.4em;
font-size: 2.4em;
color: var(--halcyon-ember);
line-height: 1;
}
/* Inline code — small ember chip on a tinted card. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose code {
font-family: var(--halcyon-mono);
font-size: 0.86em;
font-weight: 500;
color: var(--halcyon-ember);
background: var(--halcyon-ember-soft);
border: 1px solid color-mix(in srgb, var(--halcyon-ember) 22%, transparent);
border-radius: 0;
padding: 1px 6px;
}
/* Code block — dark ink card flipped against the parchment. The contrast
* is deliberate; it reads like a code excerpt set in a printed book. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose pre,
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
div[data-streamdown="code-block"]
> pre {
background: var(--halcyon-ink) !important;
color: #e8e2d5;
border: 1px solid var(--halcyon-ink);
border-radius: 0;
padding: 14px 16px;
margin: 1em 0;
font-family: var(--halcyon-mono);
font-size: 0.86em;
line-height: 1.55;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
var(--halcyon-shadow-soft);
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
pre
code {
background: transparent;
color: inherit;
border: none;
padding: 0;
}
/* Anchor links — ember underline in classic editorial style. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose a {
color: var(--halcyon-ember);
text-decoration-line: underline;
text-decoration-color: color-mix(
in srgb,
var(--halcyon-ember) 35%,
transparent
);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: text-decoration-color 160ms ease;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
a:hover {
text-decoration-color: var(--halcyon-ember);
}
/* Horizontal rule — short, centered, ornament-like. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose hr {
border: none;
height: 1px;
background: var(--halcyon-rule);
width: 64px;
margin: 1.6em auto;
}
/* @endregion[assistant-message] */
/* @region[input-composer] */
/* Composer — a sharp paper card with an ember focus rule. The default
* pill is rounded; squaring it off is again the visual cue that this is
* a different brand. The wrapper around .copilotKitInput uses a fixed
* white background in v2, so we override it directly. */
.chat-css-demo-scope .copilotKitInput {
font-family: var(--halcyon-sans) !important;
background: var(--halcyon-card) !important;
border: 1px solid var(--halcyon-rule);
border-radius: 0 !important;
padding: 14px 16px;
min-height: 56px;
box-shadow:
0 1px 0 rgba(26, 23, 20, 0.03),
0 8px 24px -16px rgba(26, 23, 20, 0.18);
transition:
border-color 200ms ease,
box-shadow 200ms ease,
transform 120ms ease;
}
.chat-css-demo-scope .copilotKitInput:focus-within {
border-color: var(--halcyon-ember);
box-shadow:
0 0 0 3px rgba(196, 74, 31, 0.12),
0 1px 0 rgba(196, 74, 31, 0.18),
0 14px 36px -16px rgba(196, 74, 31, 0.22);
transform: translateY(-1px);
}
.chat-css-demo-scope .copilotKitInput textarea {
font-family: var(--halcyon-sans) !important;
font-size: 1rem;
font-weight: 400;
color: var(--halcyon-ink);
line-height: 1.55;
letter-spacing: -0.005em;
}
.chat-css-demo-scope .copilotKitInput textarea::placeholder {
color: var(--halcyon-ink-mute);
font-style: italic;
opacity: 1;
}
/* @endregion[input-composer] */
/* @region[input-buttons] */
/* The send button — a square ember chit, not the default circular pill.
* v2 ships this as `<Button variant="chatInputToolbarPrimary"
* size="chatInputToolbarIcon" data-testid="copilot-send-button">`, which
* compiles to `cpk:bg-black cpk:text-white cpk:rounded-full cpk:h-9
* cpk:w-9`. We override every one of those tokens so the brand wins. */
.chat-css-demo-scope button[data-testid="copilot-send-button"] {
background-color: var(--halcyon-ember) !important;
color: var(--halcyon-paper-elevated) !important;
border: 1px solid var(--halcyon-ember) !important;
border-radius: 2px !important;
height: 36px !important;
width: 36px !important;
box-shadow: var(--halcyon-shadow-ember);
transition:
transform 150ms ease,
box-shadow 150ms ease,
background-color 150ms ease;
}
.chat-css-demo-scope button[data-testid="copilot-send-button"]:hover {
background-color: var(--halcyon-ember-bright) !important;
transform: translateY(-1px);
}
.chat-css-demo-scope button[data-testid="copilot-send-button"]:disabled {
background-color: var(--halcyon-paper-soft) !important;
color: var(--halcyon-ink-mute) !important;
border-color: var(--halcyon-rule) !important;
box-shadow: none;
transform: none;
opacity: 1 !important;
}
.chat-css-demo-scope button[data-testid="copilot-send-button"] svg {
color: inherit;
}
/* Secondary input chrome (add-menu plus, mic, transcribe) — ghost squares
* with an ember tint on hover. v2's `chatInputToolbarSecondary` variant
* uses transparent bg + #444 text, which we re-tint to match the brand. */
.chat-css-demo-scope
.copilotKitInput
button:not([data-testid="copilot-send-button"]) {
border-radius: 2px !important;
color: var(--halcyon-ink-soft) !important;
transition:
color 150ms ease,
background-color 150ms ease;
}
.chat-css-demo-scope
.copilotKitInput
button:not([data-testid="copilot-send-button"]):hover {
color: var(--halcyon-ember) !important;
background-color: var(--halcyon-ember-soft) !important;
}
/* @endregion[input-buttons] */
/* @region[suggestions] */
/* Suggestion pills — sharp outlined chips, not rounded balloons. The
* inner suggestion text uses the editorial mono so it reads like a
* curated set of dispatch options. */
.chat-css-demo-scope [class*="copilotKitSuggestion"] {
background: transparent;
color: var(--halcyon-ink-soft);
border: 1px solid var(--halcyon-rule);
border-radius: 0;
padding: 8px 14px;
font-family: var(--halcyon-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
transition:
color 150ms ease,
border-color 150ms ease,
background 150ms ease,
transform 150ms ease;
}
.chat-css-demo-scope [class*="copilotKitSuggestion"]:hover {
color: var(--halcyon-ember);
border-color: var(--halcyon-ember);
background: var(--halcyon-ember-soft);
transform: translateY(-1px);
}
/* @endregion[suggestions] */
/* @region[scrollbar] */
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar {
width: 4px;
}
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar-thumb {
background: var(--halcyon-rule-strong);
border-radius: 0;
}
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar-thumb:hover {
background: var(--halcyon-ember);
}
.chat-css-demo-scope [data-copilotkit] * {
scrollbar-width: thin;
scrollbar-color: var(--halcyon-rule-strong) transparent;
}
/* @endregion[scrollbar] */
/* @region[selection] */
.chat-css-demo-scope ::selection {
background: var(--halcyon-ember-soft);
color: var(--halcyon-ember);
}
/* @endregion[selection] */
/* @region[motion] */
/* A single, restrained entrance for the welcome screen — staggered fade-up
* on the eyebrow / heading / rule. No infinite loops, no bouncy easing. */
@keyframes halcyon-rise {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1 {
animation: halcyon-rise 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
animation: halcyon-rise 600ms cubic-bezier(0.2, 0.7, 0.2, 1) 80ms both;
}
@media (prefers-reduced-motion: reduce) {
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1,
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
animation: none;
}
}
/* @endregion[motion] */

Some files were not shown because too many files have changed in this diff Show More