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,229 @@
"""
Agent Server for Langroid
FastAPI server that hosts the Langroid agent backend.
The Next.js CopilotKit runtime proxies requests here via AG-UI protocol.
Langroid does not have a native AG-UI adapter, so we implement a custom
SSE endpoint that translates between Langroid's ChatAgent and the AG-UI
event stream.
"""
import os
# 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`` CVDIAG loggers
# actually EMIT, and resolves the verbosity tier + PB writer. It imports
# pydantic/starlette only (NOT langroid / openai), so it is safe to run before
# the httpx hook install below — it does not construct any LLM httpx client.
import _shared.cvdiag_bootstrap # noqa: F401 (first non-stdlib import — bootstrap side effects)
import uvicorn # noqa: E402
from fastapi import FastAPI, Request # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.responses import JSONResponse # noqa: E402
from dotenv import load_dotenv # noqa: E402
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
# imports. Langroid / openai / pydantic-ai-style adapters construct
# httpx clients eagerly at agent-module import time.
from agents._cvdiag_backend import CvdiagBackendMiddleware # noqa: E402
from agents._header_forwarding import ( # noqa: E402
HeaderForwardingHTTPMiddleware,
install_global_httpx_hook,
)
install_global_httpx_hook()
from agents.agui_adapter import handle_run
from agents.reasoning_agent import reasoning_app
from agents.a2ui_fixed_agent import handle_run as handle_a2ui_fixed_schema
from agents.byoc_hashbrown_agent import handle_run as handle_byoc_hashbrown
from agents.byoc_json_render_agent import handle_run as handle_byoc_json_render
from agents.gen_ui_agent import handle_run as handle_gen_ui_agent
from agents.mcp_apps_agent import handle_run as handle_mcp_apps
from agents.multimodal_agent import handle_run as handle_multimodal
from agents.shared_state_read_write import (
handle_run as handle_shared_state_read_write,
)
from agents.subagents import handle_run as handle_subagents
load_dotenv()
app = FastAPI(title="Langroid Agent Server")
# Serve /health via middleware so it short-circuits BEFORE route resolution.
# Applied uniformly across every showcase FastAPI agent server so /health
# remains reachable even if future changes introduce a catch-all mount at "/".
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)
app.add_middleware(HealthMiddleware)
# Capture inbound CopilotKit ``x-*`` headers (e.g. ``x-aimock-context``)
# into a per-request ContextVar so any outbound LLM/provider httpx call
# made inside the request scope copies them onto its outbound request.
# Paired with ``install_global_httpx_hook`` at the top of this file.
app.add_middleware(HeaderForwardingHTTPMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# 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 LAST so
# it is the OUTERMOST layer: it observes ingress before any inner layer mutates
# the request and wraps the response stream so SSE boundaries fire as chunks
# flow. 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)
@app.post("/")
async def run_agent(request: Request):
"""AG-UI /run endpoint — streams SSE events."""
return await handle_run(request)
# Reasoning-aware sub-app. Langroid's stock unified adapter calls OpenAI
# non-streaming and reads only message.content / message.tool_calls — it
# drops the model's reasoning_content channel, so the reasoning-default /
# reasoning-custom cells can never light up CopilotKit's reasoning slot via
# the unified agent. This custom sub-app streams the chat-completions call
# directly, captures delta.reasoning_content, and emits REASONING_MESSAGE_*
# events. The HttpAgent posts to /reasoning/; the outer Mount strips
# /reasoning and the inner Mount at "/" resolves ReasoningEndpoint. Mirrors
# ag2's /reasoning mount.
app.mount("/reasoning", reasoning_app)
# Per-demo endpoints for cells that need state-aware behavior the unified
# agent does not provide. Each handler implements its own AG-UI SSE
# pipeline (RUN_STARTED / STATE_SNAPSHOT / TEXT_* / TOOL_CALL_* / RUN_FINISHED)
# so it can read RunAgentInput.state and emit fresh snapshots when its
# tools mutate shared state. The Next.js runtime routes the demo's
# CopilotKit calls to /api/copilotkit-<slug>, which proxies to these
# endpoints via per-demo HttpAgent instances.
@app.post("/shared-state-read-write")
async def run_shared_state_read_write(request: Request):
"""Shared State (Read + Write) demo endpoint.
The UI writes ``preferences`` into agent state via ``agent.setState``;
the handler injects them into the system prompt every turn. The agent
writes ``notes`` via the ``set_notes`` tool; the handler emits a
STATE_SNAPSHOT so the UI re-renders.
"""
return await handle_shared_state_read_write(request)
@app.post("/gen-ui-agent")
async def run_gen_ui_agent(request: Request):
"""Agentic Generative UI demo endpoint.
The agent owns a ``steps`` slice of shared state and walks each step
pending -> in_progress -> completed by repeatedly calling a custom
``set_steps`` tool. Each call mutates local state and emits a fresh
STATE_SNAPSHOT so the UI's ``useAgent`` subscriber re-renders the
progress card in place.
"""
return await handle_gen_ui_agent(request)
@app.post("/subagents")
async def run_subagents(request: Request):
"""Sub-Agents demo endpoint.
A supervisor LLM delegates to research / writing / critique sub-agents
via tool calls. Each delegation appends a Delegation entry to
``state["delegations"]`` (running -> completed/failed) and emits a
STATE_SNAPSHOT so the UI's live delegation log updates.
"""
return await handle_subagents(request)
@app.post("/multimodal")
async def run_multimodal(request: Request):
"""Multimodal demo endpoint — vision-capable (gpt-4o).
Forwards image attachments to the model natively; flattens PDFs to
text via pypdf so the model can read them without needing file-part
support on the OpenAI API side.
"""
return await handle_multimodal(request)
@app.post("/byoc-hashbrown")
async def run_byoc_hashbrown(request: Request):
"""BYOC: Hashbrown demo endpoint.
Emits a hashbrown-shaped JSON envelope (`{"ui": [...]}`) that the
frontend's `useJsonParser` + `useUiKit` parses progressively as the
response streams.
"""
return await handle_byoc_hashbrown(request)
@app.post("/byoc-json-render")
async def run_byoc_json_render(request: Request):
"""BYOC: json-render demo endpoint.
Emits a flat element-map spec (`{"root", "elements"}`) that
@json-render/react renders against a Zod-validated catalog.
"""
return await handle_byoc_json_render(request)
@app.post("/a2ui-fixed-schema")
async def run_a2ui_fixed_schema(request: Request):
"""A2UI Fixed Schema demo endpoint.
The agent ships ``flight_schema.json`` as a fixed component tree and
only streams *data* into the data model at runtime. The
``display_flight`` tool returns an ``a2ui_operations`` container
(``create_surface`` + ``update_components`` + ``update_data_model``)
that the Next.js A2UI middleware detects and forwards to the
frontend renderer. The dedicated runtime route at
``api/copilotkit-a2ui-fixed-schema/route.ts`` is configured with
``injectA2UITool: false`` because the agent owns the tool itself.
"""
return await handle_a2ui_fixed_schema(request)
@app.post("/mcp-apps")
async def run_mcp_apps(request: Request):
"""MCP Apps demo endpoint.
Forwards the runtime-supplied MCP tool catalog to OpenAI; the runtime
middleware on the TypeScript side intercepts the resulting tool
calls, fetches the MCP UI resource, and renders the sandboxed
iframe.
"""
return await handle_mcp_apps(request)
def main():
"""Run the uvicorn server."""
port = int(os.getenv("PORT", "8000"))
uvicorn.run(
"agent_server:app",
host="0.0.0.0",
port=port,
reload=True,
)
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 = "langroid"
# ── 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,403 @@
"""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 = "langroid"
# 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
@@ -0,0 +1,464 @@
"""Langroid 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 ``ag2`` integration's ``a2ui_fixed.py`` and the
``langgraph-python`` reference. The dedicated Next.js route at
``api/copilotkit-a2ui-fixed-schema/route.ts`` runs the A2UI middleware
with ``injectA2UITool: false`` because the backend agent owns the
``display_flight`` tool itself and emits an ``a2ui_operations`` container
in the tool result.
Wire pattern
------------
On each request we call OpenAI with a single ``display_flight`` tool
forced via ``tool_choice`` when the user prompt looks like a flight
query. The handler emits an AG-UI ``ToolCall`` triple for each tool call
the model produces, then immediately appends the ``a2ui_operations``
JSON as a tool-result text block. The runtime A2UI middleware on the
TypeScript side detects the ``a2ui_operations`` shape and forwards the
surface to the frontend renderer.
This handler is wired up by ``agent_server.py`` at
``POST /a2ui-fixed-schema``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from pathlib import Path
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
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[str, Any]]:
"""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")
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."
)
# OpenAI tool spec — single ``display_flight`` tool.
_DISPLAY_FLIGHT_TOOL: dict[str, Any] = {
"type": "function",
"function": {
"name": "display_flight",
"description": "Show a flight card for the given trip.",
"parameters": {
"type": "object",
"properties": {
"origin": {
"type": "string",
"description": "Origin airport code, e.g. 'SFO'.",
},
"destination": {
"type": "string",
"description": "Destination airport code, e.g. 'JFK'.",
},
"airline": {
"type": "string",
"description": "Airline name, e.g. 'United'.",
},
"price": {
"type": "string",
"description": "Price string, e.g. '$289'.",
},
},
"required": ["origin", "destination", "airline", "price"],
},
},
}
def _build_a2ui_operations(
*, origin: str, destination: str, airline: str, price: str
) -> dict[str, Any]:
"""Build the ``a2ui_operations`` container (v0.9 shape) the runtime
middleware detects in TOOL_CALL_RESULT events and forwards to the
frontend renderer.
Uses the v0.9 nested form (``{"version": "v0.9", "createSurface": {...}}``)
matching the ``copilotkit`` Python SDK's ``a2ui.render(...)`` output and
the claude-sdk-python / strands / google-adk peers. The legacy flat form
(``{"type": "create_surface", ...}``) is silently dropped by the renderer.
"""
return {
"a2ui_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,
},
},
},
]
}
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _agui_messages_to_openai(messages: Any) -> list[dict[str, Any]]:
"""Reduce inbound AG-UI messages to a simple OpenAI message list.
We only need text-bearing user/assistant turns plus prior
``tool``-role messages keyed by ``tool_call_id`` so the model can
follow up after a ``display_flight`` call. Anything else is
skipped.
"""
out: list[dict[str, Any]] = []
if not messages:
return out
for msg in messages:
if isinstance(msg, dict):
role = msg.get("role")
content = msg.get("content")
tool_call_id = msg.get("tool_call_id")
tool_calls = msg.get("tool_calls")
else:
role = getattr(msg, "role", None)
content = getattr(msg, "content", None)
tool_call_id = getattr(msg, "tool_call_id", None)
tool_calls = getattr(msg, "tool_calls", None)
if role == "tool" and tool_call_id:
out.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content or ""),
}
)
continue
if role == "assistant":
oai_msg: dict[str, Any] = {"role": "assistant"}
if isinstance(content, str) and content:
oai_msg["content"] = content
if tool_calls:
oai_tcs = []
for tc in tool_calls:
if isinstance(tc, dict):
tc_id = tc.get("id")
fn = tc.get("function", {})
fn_name = fn.get("name", "") if isinstance(fn, dict) else ""
fn_args = (
fn.get("arguments", "") if isinstance(fn, dict) else ""
)
else:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
oai_tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if oai_tcs:
oai_msg["tool_calls"] = oai_tcs
if "content" not in oai_msg:
oai_msg["content"] = None
if "content" not in oai_msg and "tool_calls" not in oai_msg:
oai_msg["content"] = ""
out.append(oai_msg)
continue
if role in ("user", "system", "developer") and isinstance(content, str):
out.append({"role": role, "content": content})
return out
def _parse_tool_args(raw: Any) -> dict[str, Any] | None:
"""Parse OpenAI tool-call arguments (JSON string or dict) into a dict."""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw or "{}")
except (ValueError, TypeError):
logger.warning("a2ui_fixed: failed to parse tool args: %r", raw)
return None
if isinstance(parsed, dict):
return parsed
return None
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/a2ui-fixed-schema`` SSE handler.
Drives a single OpenAI chat-completions turn with the
``display_flight`` tool exposed. If the model produces a tool call,
we emit AG-UI ``TOOL_CALL_*`` events plus a tool-result text block
containing the ``a2ui_operations`` JSON the Next.js runtime A2UI
middleware detects and forwards to the frontend renderer.
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("a2ui_fixed: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("a2ui_fixed: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
oai_messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*_agui_messages_to_openai(run_input.messages),
]
model = os.getenv("LANGROID_MODEL", "gpt-4o-mini")
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
client = openai.AsyncOpenAI()
try:
completion = await client.chat.completions.create(
model=model,
messages=oai_messages,
tools=[_DISPLAY_FLIGHT_TOOL],
tool_choice="auto",
stream=False,
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("a2ui_fixed: OpenAI call failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
message = completion.choices[0].message if completion.choices else None
text_content = getattr(message, "content", None) or ""
tool_calls = getattr(message, "tool_calls", None) or []
# Parent message wraps tool calls so the runtime middleware
# SSE parser can associate them — same pattern as the main
# adapter (see agui_adapter.py).
parent_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=parent_id,
)
)
if text_content:
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=parent_id,
delta=text_content,
)
)
for tc in tool_calls:
fn = getattr(tc, "function", None)
tool_name = getattr(fn, "name", None) if fn else None
raw_args = getattr(fn, "arguments", "{}") if fn else "{}"
tool_args = _parse_tool_args(raw_args)
call_id = getattr(tc, "id", None) or str(uuid.uuid4())
if tool_name != "display_flight" or tool_args is None:
logger.warning(
"a2ui_fixed: skipping unexpected tool call %s", tool_name
)
continue
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=call_id,
tool_call_name=tool_name,
parent_message_id=parent_id,
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=call_id,
delta=json.dumps(tool_args),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=call_id,
)
)
# Build the tool result containing the a2ui_operations
# container. The Next.js runtime A2UI middleware detects
# this shape in TOOL_CALL_RESULT events and forwards the
# surface ops to the frontend renderer.
operations = _build_a2ui_operations(
origin=str(tool_args.get("origin", "")),
destination=str(tool_args.get("destination", "")),
airline=str(tool_args.get("airline", "")),
price=str(tool_args.get("price", "")),
)
tool_result_msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=parent_id,
)
)
# Emit the a2ui_operations container via TOOL_CALL_RESULT so
# the A2UI middleware detects it. TextMessageContentEvent is
# not scanned for a2ui_operations — only tool results are.
yield _sse_line(
ToolCallResultEvent(
type=EventType.TOOL_CALL_RESULT,
tool_call_id=call_id,
message_id=tool_result_msg_id,
content=json.dumps(operations),
)
)
# Single tool call per turn — finish after the first.
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
# No tool call path — close the parent message and finish.
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=parent_id,
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -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"
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,299 @@
"""BYOC: Hashbrown demo backend (Langroid).
Streams a single JSON object shaped like `@hashbrownai/react`'s
`useUiKit` schema so the frontend's progressive parser can turn it into
a sales dashboard as tokens arrive.
Wire format
-----------
The frontend (see ``src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx``)
calls ``useJsonParser(content, kit.schema)``. ``kit.schema`` matches:
{
"ui": [
{ "metric": { "props": { "label": "...", "value": "..." } } },
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "dealCard": { "props": { "title": "...", "stage": "...", "value": 0 } } },
{ "Markdown": { "props": { "children": "..." } } }
]
}
This handler forces OpenAI's ``response_format: json_object`` mode and
streams the result as a single ``TEXT_MESSAGE`` triple. The progressive
parser on the frontend treats partial JSON gracefully — anything that
doesn't parse yet falls back to a no-op render until the next token
arrives.
The handler is wired up by ``agent_server.py`` at ``POST
/byoc-hashbrown``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
# Mirrors the langgraph-python byoc_hashbrown system prompt. The
# example payload at the bottom is critical — without a worked example
# the model frequently emits the wrong nesting (e.g. multi-key objects
# instead of single-key `{tagName: {props: {...}}}` entries).
_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}]"}}}]}
"""
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _flatten_user_messages(messages: Any) -> list[dict[str, Any]]:
"""Reduce inbound AG-UI messages to a simple OpenAI message list.
The hashbrown demo is single-turn-ish — we want the model to emit a
fresh JSON envelope for the latest user prompt, not a continuation.
Accept all `user`/`assistant` text-only turns; skip tool messages
(irrelevant — this agent has no tools).
"""
out: list[dict[str, Any]] = []
if not messages:
return out
for msg in messages:
role = (
getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
)
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
if (
isinstance(role, str)
and role in ("user", "assistant")
and isinstance(content, str)
):
out.append({"role": role, "content": content})
return out
async def _stream_json_response(
*,
system_prompt: str,
user_messages: list[dict[str, Any]],
model: str,
) -> AsyncGenerator[str, None]:
"""Yield raw JSON text deltas from OpenAI streaming chat completion."""
client = openai.AsyncOpenAI()
stream = await client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": system_prompt}, *user_messages],
response_format={"type": "json_object"},
stream=True,
)
async for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
text = getattr(delta, "content", None)
if text:
yield text
async def _run_byoc(
*,
system_prompt: str,
request: Request,
default_model: str,
) -> StreamingResponse:
"""Shared SSE plumbing for both BYOC demos.
Both endpoints differ only in their system prompt — extracted so the
sister `byoc_json_render_agent` module can call straight in without
duplicating the parsing / streaming / error envelope.
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("byoc: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("byoc: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
user_messages = _flatten_user_messages(run_input.messages)
model = os.getenv("LANGROID_MODEL", default_model)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
message_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=message_id
)
)
try:
async for delta in _stream_json_response(
system_prompt=system_prompt,
user_messages=user_messages,
model=model,
):
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=message_id,
delta=delta,
)
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("byoc: OpenAI streaming call failed")
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END, message_id=message_id
)
)
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/byoc-hashbrown`` SSE handler."""
return await _run_byoc(
system_prompt=_SYSTEM_PROMPT,
request=request,
default_model="gpt-4o-mini",
)
@@ -0,0 +1,158 @@
"""BYOC: json-render demo backend (Langroid).
Emits a single JSON object shaped like ``@json-render/react``'s flat
spec (``{ root, elements }``) so the frontend can feed it directly into
``<Renderer />`` against a Zod-validated catalog of three components —
MetricCard, BarChart, PieChart.
Mirrors the langgraph-python sibling (and matches Wave 4a's hashbrown
shape so the two BYOC rows on the dashboard are directly comparable).
The handler delegates the SSE plumbing to ``byoc_hashbrown_agent._run_byoc``
— only the system prompt differs.
Wired up by ``agent_server.py`` at ``POST /byoc-json-render``.
"""
from __future__ import annotations
from fastapi import Request
from fastapi.responses import StreamingResponse
from agents.byoc_hashbrown_agent import _run_byoc
_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 }
]
}
}
}
}
### Worked example — "Break down revenue by category as a pie chart"
{
"root": "category-pie",
"elements": {
"category-pie": {
"type": "PieChart",
"props": {
"title": "Revenue by category",
"description": "Share of total revenue by product category",
"data": [
{ "label": "Enterprise", "value": 540000 },
{ "label": "SMB", "value": 310000 },
{ "label": "Self-serve", "value": 220000 },
{ "label": "Partner", "value": 170000 }
]
}
}
}
}
### Worked example — "Show me monthly expenses as a bar chart"
{
"root": "expense-bar",
"elements": {
"expense-bar": {
"type": "BarChart",
"props": {
"title": "Monthly expenses",
"description": "Operating expenses by month",
"data": [
{ "label": "Jul", "value": 210000 },
{ "label": "Aug", "value": 225000 },
{ "label": "Sep", "value": 240000 }
]
}
}
}
}
Respond with the JSON object only.
"""
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/byoc-json-render`` SSE handler."""
return await _run_byoc(
system_prompt=_SYSTEM_PROMPT,
request=request,
default_model="gpt-4o-mini",
)
@@ -0,0 +1,613 @@
"""gen-ui-agent demo — Langroid.
Mirrors ``langgraph-python/src/agents/gen_ui_agent.py`` and
``ms-agent-python/src/agents/gen_ui_agent.py``: the agent owns an explicit
``steps`` slice of shared state (each step is ``{id, title, status}``
with status in ``pending`` / ``in_progress`` / ``completed``) and walks
each step pending → in_progress → completed by repeatedly calling a
custom ``set_steps`` tool. Every ``set_steps`` call mutates the local
state dict and emits a fresh AG-UI ``STATE_SNAPSHOT`` so the frontend's
``useAgent`` subscriber re-renders the progress card in place.
Langroid does not provide a native shared-state channel — we implement
it directly on top of AG-UI's ``STATE_SNAPSHOT`` event, mirroring the
posture taken by ``shared_state_read_write.py``. The LLM is driven via
the OpenAI client directly (not langroid's ``ChatAgent``) so the
multi-turn tool-call loop has explicit control over state mutation and
event emission per iteration, and so aimock can intercept and
fixture-match each request by message history shape.
The handler is wired up by ``agent_server.py`` at ``POST /gen-ui-agent``
and reached from the frontend via the ``gen-ui-agent`` entry in
``src/app/api/copilotkit/route.ts``, which points its ``HttpAgent`` at
``${AGENT_URL}/gen-ui-agent``.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
import openai
logger = logging.getLogger(__name__)
# =====================================================================
# State shape (mirrors the UI's AgentState.steps in
# src/app/demos/gen-ui-agent/InlineAgentStateCard.tsx).
# =====================================================================
#
# { "steps": [ { "id": str, "title": str,
# "status": "pending" | "in_progress" | "completed" }, ... ] }
#
# Owned by the agent. The UI only READS via useAgent; it never writes
# back into this slice.
_VALID_STATUSES = frozenset({"pending", "in_progress", "completed"})
def _normalize_state(raw: Any) -> dict[str, Any]:
"""Coerce inbound RunAgentInput.state into our canonical dict.
AG-UI types ``state`` as ``Any``. A malformed frontend (or fresh
session shipping ``None``) is treated as "empty plan" — we don't
try to reconstruct shape from non-dicts. Steps that aren't dicts,
or that lack the canonical keys, are dropped silently.
"""
if not isinstance(raw, dict):
return {"steps": []}
steps_raw = raw.get("steps")
if not isinstance(steps_raw, list):
return {"steps": []}
steps: list[dict[str, Any]] = []
for s in steps_raw:
if not isinstance(s, dict):
continue
status = s.get("status")
if not isinstance(status, str) or status not in _VALID_STATUSES:
continue
title = s.get("title")
if not isinstance(title, str):
continue
step_id = s.get("id")
if not isinstance(step_id, str) or not step_id:
step_id = str(uuid.uuid4())
steps.append({"id": step_id, "title": title, "status": status})
return {"steps": steps}
def _sanitize_steps(raw: Any) -> list[dict[str, Any]] | None:
"""Coerce a ``set_steps`` argument into a clean steps list.
Returns ``None`` if the input isn't a list at all — the caller will
skip the snapshot emission rather than blank out the UI. Invalid
individual entries are dropped (defense in depth — the prompt is
strict, but a misbehaving model shouldn't break the UI).
"""
if not isinstance(raw, list):
return None
out: list[dict[str, Any]] = []
for s in raw:
if not isinstance(s, dict):
continue
status = s.get("status")
if not isinstance(status, str) or status not in _VALID_STATUSES:
continue
title = s.get("title")
if not isinstance(title, str):
continue
step_id = s.get("id")
if not isinstance(step_id, str) or not step_id:
step_id = str(uuid.uuid4())
out.append({"id": step_id, "title": title, "status": status})
return out
# =====================================================================
# ``set_steps`` tool — OpenAI function spec.
# =====================================================================
_SET_STEPS_TOOL_SPEC: dict[str, Any] = {
"type": "function",
"function": {
"name": "set_steps",
"description": (
"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 — "
"this REPLACES the steps array in shared state."
),
"parameters": {
"type": "object",
"properties": {
"steps": {
"type": "array",
"description": (
"The complete source of truth for the plan: every "
"step with id, title, and status."
),
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Stable identifier for the step.",
},
"title": {
"type": "string",
"description": "Short human-readable description.",
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
},
},
"required": ["id", "title", "status"],
},
},
},
"required": ["steps"],
},
},
}
_SYSTEM_PROMPT = (
"You are an agentic planner. For each user request, follow this exact "
"sequence:\n"
"1. Plan exactly 3 concrete steps and call `set_steps` ONCE with all "
'three steps at status="pending".\n'
'2. Step 1: call `set_steps` with step 1 at status="in_progress", '
'then call `set_steps` again with step 1 at status="completed".\n'
'3. Step 2: call `set_steps` with step 2 at status="in_progress", '
'then call `set_steps` again with step 2 at status="completed".\n'
'4. Step 3: call `set_steps` with step 3 at status="in_progress", '
'then call `set_steps` again with step 3 at status="completed".\n'
"5. Send ONE final conversational assistant message summarizing the "
"plan, then stop. Do not call any more tools after step 3 is "
"completed.\n"
"\n"
"Rules: never call set_steps in parallel — always wait for one call "
"to return before the next. Always send the FULL steps list on every "
"call (this REPLACES the array). After all three steps are completed "
"you MUST send a final assistant message and terminate."
)
# Bound on the tool-call loop. The prompt drives ~7 set_steps calls + 1
# final assistant turn, so 12 iterations gives ~70% headroom for retries
# without runaway cost if the model misbehaves.
_MAX_TOOL_ITERATIONS = 12
async def _call_openai(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> Any:
"""Call the OpenAI chat completions API directly.
Uses ``openai.AsyncOpenAI()`` which reads ``OPENAI_API_KEY`` and
``OPENAI_BASE_URL`` from the environment (aimock sets the base URL
in the showcase).
"""
model = os.getenv("LANGROID_MODEL", "gpt-4.1")
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model=model,
messages=messages,
tools=tools if tools else openai.NOT_GIVEN,
)
return response.choices[0].message
# =====================================================================
# AG-UI SSE helpers
# =====================================================================
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _agui_messages_to_openai(
messages: Any,
system_prompt: str,
) -> list[dict[str, Any]]:
"""Convert AG-UI messages to OpenAI chat completion format.
Mirrors ``shared_state_read_write._agui_messages_to_openai`` and the
main ``agui_adapter`` — preserves ``tool_calls`` and ``tool_call_id``
so aimock fixture matchers see the full multi-turn shape.
"""
oai_msgs: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
]
if not messages:
return oai_msgs
for msg in messages:
role = getattr(msg, "role", None)
if not isinstance(role, str):
if isinstance(msg, dict):
role = msg.get("role")
if not isinstance(role, str):
continue
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None)
if isinstance(msg, dict):
tool_call_id = tool_call_id or msg.get("tool_call_id")
content = getattr(msg, "content", "") or ""
if isinstance(msg, dict):
content = content or msg.get("content", "")
if tool_call_id:
oai_msgs.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content),
}
)
continue
if role == "assistant":
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
tool_calls_raw = getattr(msg, "tool_calls", None)
if isinstance(msg, dict):
tool_calls_raw = tool_calls_raw or msg.get("tool_calls")
oai_msg: dict[str, Any] = {"role": "assistant"}
if content:
oai_msg["content"] = str(content)
if tool_calls_raw:
oai_tcs = []
for tc in tool_calls_raw:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
if fn is None and isinstance(tc, dict):
fn_name = tc.get("function", {}).get("name", "")
fn_args = tc.get("function", {}).get("arguments", "")
tc_id = tc_id or tc.get("id", "")
else:
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
oai_tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if oai_tcs:
oai_msg["tool_calls"] = oai_tcs
if "content" not in oai_msg:
oai_msg["content"] = None
else:
if "content" not in oai_msg:
oai_msg["content"] = ""
oai_msgs.append(oai_msg)
continue
if role in ("user", "system", "developer"):
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
if content is not None:
oai_msgs.append(
{
"role": role,
"content": str(content),
}
)
continue
return oai_msgs
def _extract_set_steps_calls(
response: Any,
) -> list[tuple[str, list[dict[str, Any]] | None, str]]:
"""Return ``(tool_call_id, sanitized_steps, raw_args_str)`` per
``set_steps`` call in the response.
Non-set_steps tool calls are skipped (defense — the model only has
one tool, but if it ever hallucinates another we'd rather ignore
than crash). ``sanitized_steps`` is ``None`` if the args couldn't
be coerced into a steps list — caller skips snapshot emission for
that entry.
"""
out: list[tuple[str, list[dict[str, Any]] | None, str]] = []
tool_calls = getattr(response, "tool_calls", None) or []
for tc in tool_calls:
fn = getattr(tc, "function", None)
name = getattr(fn, "name", None) if fn is not None else None
if name != "set_steps":
continue
tc_id = getattr(tc, "id", None) or str(uuid.uuid4())
raw_args = getattr(fn, "arguments", None) if fn is not None else None
args_str = raw_args if isinstance(raw_args, str) else json.dumps(raw_args or {})
parsed: Any = raw_args
if isinstance(raw_args, (str, bytes, bytearray)):
try:
parsed = json.loads(raw_args)
except (ValueError, TypeError):
out.append((str(tc_id), None, args_str))
continue
if not isinstance(parsed, dict):
out.append((str(tc_id), None, args_str))
continue
sanitized = _sanitize_steps(parsed.get("steps"))
out.append((str(tc_id), sanitized, args_str))
return out
# =====================================================================
# AG-UI SSE handler
# =====================================================================
async def handle_run(request: Request) -> StreamingResponse:
"""Handle one AG-UI ``/gen-ui-agent`` request.
Drives a bounded multi-turn loop with the LLM: each ``set_steps``
tool call updates local state and emits a fresh ``STATE_SNAPSHOT``
plus ``TOOL_CALL_*`` events, then feeds the tool result back to the
model for the next turn. The loop terminates when the model emits a
final text response (or, defensively, after ``_MAX_TOOL_ITERATIONS``
iterations).
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("gen-ui-agent: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except Exception as exc: # noqa: BLE001 — pydantic.ValidationError surfaces here
logger.exception("gen-ui-agent: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
state = _normalize_state(run_input.state)
oai_messages = _agui_messages_to_openai(run_input.messages or [], _SYSTEM_PROMPT)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
# Initial baseline snapshot so a fresh session always sees the
# empty (or restored) steps array before the agent writes the
# plan. Mirrors shared_state_read_write's initial snapshot.
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
messages = list(oai_messages)
for _ in range(_MAX_TOOL_ITERATIONS):
try:
response = await _call_openai(messages, [_SET_STEPS_TOOL_SPEC])
except Exception as exc: # noqa: BLE001 — surface as RunError + finish
logger.exception("gen-ui-agent: _call_openai failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
if response is None:
break
calls = _extract_set_steps_calls(response)
if not calls:
# No tool call this turn — stream any text and finish.
content = getattr(response, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=msg_id,
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=msg_id,
)
)
break
# Apply each set_steps call: update state, emit TOOL_CALL_*
# + STATE_SNAPSHOT, and accumulate the assistant + tool
# result messages for the follow-up LLM turn.
assistant_tool_calls: list[dict[str, Any]] = []
tool_result_msgs: list[dict[str, Any]] = []
for call_id, sanitized, raw_args_str in calls:
if sanitized is None:
logger.warning(
"gen-ui-agent: skipping set_steps call %s — args could "
"not be parsed as steps list",
call_id,
)
# Still record the tool result so the model's message
# history stays coherent and it can retry.
assistant_tool_calls.append(
{
"id": call_id,
"type": "function",
"function": {
"name": "set_steps",
"arguments": raw_args_str,
},
}
)
tool_result_msgs.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": "Invalid steps payload — please retry with a list of steps.",
}
)
continue
state["steps"] = sanitized
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=call_id,
tool_call_name="set_steps",
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=call_id,
delta=json.dumps({"steps": sanitized}),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=call_id,
)
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
assistant_tool_calls.append(
{
"id": call_id,
"type": "function",
"function": {
"name": "set_steps",
"arguments": raw_args_str,
},
}
)
tool_result_msgs.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": f"Published {len(sanitized)} step(s).",
}
)
# Append the assistant turn (with its tool_calls) + the tool
# results, so the next LLM call sees the full conversation
# and can decide to transition the next step or finalize.
messages.append(
{
"role": "assistant",
"content": None,
"tool_calls": assistant_tool_calls,
}
)
messages.extend(tool_result_msgs)
else:
logger.warning(
"gen-ui-agent: hit _MAX_TOOL_ITERATIONS=%d without a final "
"text turn — terminating the run",
_MAX_TOOL_ITERATIONS,
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,391 @@
"""MCP Apps demo backend (Langroid).
The CopilotKit runtime is wired with ``mcpApps: { servers: [...] }`` (see
``src/app/api/copilotkit-mcp-apps/route.ts``); the runtime auto-applies
the MCP Apps middleware, which injects the remote MCP server's tools
into the AG-UI run input at request time and emits the activity events
that CopilotKit's built-in ``MCPAppsActivityRenderer`` renders inline
in the chat as a sandboxed iframe.
Because the tool catalog is supplied by the runtime per-request (in
``RunAgentInput.tools``), the Python agent for this demo does **not**
declare any langroid ``ToolMessage`` subclasses. We forward the inbound
tool list straight to the OpenAI chat completions API and surface
whatever tool calls the model emits — the runtime middleware on the
TypeScript side picks them up, fetches the MCP UI resource, and emits
the activity events that render the iframe.
The handler is wired up by ``agent_server.py`` at ``POST /mcp-apps``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
# Speed-biased system prompt — mirrors the langgraph-python MCP Apps
# agent. We want one fast tool call that produces a correct-enough
# diagram; we are not optimizing for polish.
_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.
"""
# ---------------------------------------------------------------------------
# AG-UI message → OpenAI message conversion (compact mirror of agui_adapter)
# ---------------------------------------------------------------------------
def _agui_messages_to_openai(messages: Any, system_prompt: str) -> list[dict[str, Any]]:
"""Translate AG-UI typed messages into OpenAI chat completion shape.
Mirrors the conversion in ``agents.agui_adapter`` but is duplicated
locally to keep this module's dependency surface small (the unified
adapter pulls in ``ALL_TOOLS`` and other unrelated machinery).
"""
out: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
if not messages:
return out
for msg in messages:
role = (
getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
)
if not isinstance(role, str):
continue
if role == "tool":
tool_call_id = (
getattr(msg, "tool_call_id", None)
if not isinstance(msg, dict)
else msg.get("tool_call_id")
)
content = (
getattr(msg, "content", "")
if not isinstance(msg, dict)
else msg.get("content", "")
) or ""
if tool_call_id:
out.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content),
}
)
continue
if role == "assistant":
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
tool_calls_raw = (
getattr(msg, "tool_calls", None)
if not isinstance(msg, dict)
else msg.get("tool_calls")
)
entry: dict[str, Any] = {"role": "assistant"}
if content:
entry["content"] = str(content)
if tool_calls_raw:
tcs: list[dict[str, Any]] = []
for tc in tool_calls_raw:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
if fn is None and isinstance(tc, dict):
fn_name = (tc.get("function") or {}).get("name", "")
fn_args = (tc.get("function") or {}).get("arguments", "")
tc_id = tc_id or tc.get("id", "")
else:
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if tcs:
entry["tool_calls"] = tcs
if "content" not in entry:
# OpenAI requires content to be null (not missing)
# when tool_calls are present.
entry["content"] = None
else:
if "content" not in entry:
entry["content"] = ""
out.append(entry)
continue
if role in ("user", "system", "developer"):
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
if isinstance(content, str):
out.append({"role": role, "content": content})
return out
def _runtime_tools_to_openai(tools: Any) -> list[dict[str, Any]]:
"""Convert the AG-UI ``RunAgentInput.tools`` array into OpenAI shape.
The MCP Apps middleware on the TypeScript side advertises the remote
MCP server's tools to the agent via this field. Each tool is a
``{ name, description, parameters }`` triple.
"""
if not tools:
return []
converted: list[dict[str, Any]] = []
for tool in tools:
name = (
getattr(tool, "name", None)
if not isinstance(tool, dict)
else tool.get("name")
)
if not isinstance(name, str) or not name:
continue
description = (
getattr(tool, "description", "")
if not isinstance(tool, dict)
else tool.get("description", "")
) or ""
parameters = (
getattr(tool, "parameters", None)
if not isinstance(tool, dict)
else tool.get("parameters")
)
if parameters is None:
parameters = {"type": "object", "properties": {}}
converted.append(
{
"type": "function",
"function": {
"name": name,
"description": str(description),
"parameters": parameters,
},
}
)
return converted
# ---------------------------------------------------------------------------
# SSE plumbing
# ---------------------------------------------------------------------------
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/mcp-apps`` SSE handler — streams text + tool-call events."""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("mcp-apps: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("mcp-apps: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
oai_messages = _agui_messages_to_openai(run_input.messages, _SYSTEM_PROMPT)
oai_tools = _runtime_tools_to_openai(getattr(run_input, "tools", None))
model = os.getenv("LANGROID_MODEL", "gpt-4o-mini")
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
try:
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model=model,
messages=oai_messages,
tools=oai_tools if oai_tools else openai.NOT_GIVEN,
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("mcp-apps: OpenAI call failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
choice = response.choices[0].message if response.choices else None
if choice is None:
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
# Emit any tool calls the model produced. The MCP Apps middleware on
# the TypeScript side intercepts these to fetch the UI resource and
# render the activity iframe.
tool_calls = getattr(choice, "tool_calls", None) or []
for tc in tool_calls:
tc_id = str(getattr(tc, "id", None) or uuid.uuid4())
fn = getattr(tc, "function", None)
tc_name = getattr(fn, "name", "") if fn else ""
tc_args = getattr(fn, "arguments", "") if fn else ""
if not tc_name:
continue
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=tc_id,
tool_call_name=tc_name,
)
)
if tc_args:
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=tc_id,
delta=str(tc_args),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=tc_id,
)
)
# Surface any narration text the model produced alongside the tool
# call. Many models reply with both a one-liner and a tool call.
content = getattr(choice, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=msg_id
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=msg_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,382 @@
"""Multimodal demo backend (Langroid).
Vision-capable agent (gpt-4o) for the ``/demos/multimodal`` cell. Accepts
image and PDF attachments injected via CopilotChat's AttachmentsConfig
pipeline. Mirrors the langgraph-python sibling
(``showcase/integrations/langgraph-python/src/agents/multimodal_agent.py``)
so the two demos exercise comparable behavior.
Wire format
===========
Attachments arrive in user-message content as either:
1. ``{"type": "image", "source": {"type": "data", "value": "<b64>",
"mimeType": "image/png"}}`` — modern AG-UI shape that CopilotChat
emits natively.
2. ``{"type": "binary", "mimeType": "application/pdf", "data": "<b64>"}``
— legacy AG-UI binary part the langgraph-python integration's
``onRunInitialized`` shim normalizes to. Kept for interop in case a
future runtime path forwards through that converter.
Image parts are forwarded to OpenAI as ``image_url`` content parts with
inline ``data:<mime>;base64,...`` URLs; gpt-4o reads them natively. PDF
parts are flattened to text with ``pypdf`` (gpt-4o cannot read PDFs
directly), with a typed placeholder when extraction fails so the model
can at least tell the user the document was unreadable.
Wired up by ``agent_server.py`` at ``POST /multimodal``.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
_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."
)
# ---------------------------------------------------------------------------
# PDF flattening
# ---------------------------------------------------------------------------
def _extract_pdf_text(b64: str) -> str:
"""Decode an inline-base64 PDF and extract its text.
Returns an empty string when decoding or extraction fails. Caller
decides whether to inline the text or substitute a placeholder.
"""
try:
raw = base64.b64decode(b64, validate=False)
except (ValueError, TypeError) as exc:
logger.warning("multimodal: base64 decode failed: %s", exc)
return ""
try:
# Lazy import — keeps the module importable even if pypdf is
# missing at dev-server boot.
from pypdf import PdfReader # type: ignore[import-not-found]
except ImportError as exc:
logger.warning(
"multimodal: pypdf not installed — PDF text unavailable: %s", exc
)
return ""
try:
reader = PdfReader(io.BytesIO(raw))
pages = [page.extract_text() or "" for page in reader.pages]
return "\n\n".join(pages).strip()
except Exception as exc: # noqa: BLE001 — pypdf failure shouldn't 500 the run
logger.warning("multimodal: pypdf extraction failed: %s", exc)
return ""
# ---------------------------------------------------------------------------
# Content-part normalization
# ---------------------------------------------------------------------------
def _normalize_part(part: Any) -> dict[str, Any] | None:
"""Map an inbound content part to an OpenAI content part.
Returns ``None`` when the part is not understood (caller can drop or
fall back to text). Recognises:
- ``{"type": "text", "text": "..."}`` — passthrough.
- ``{"type": "image", "source": {"type": "data", "value": "<b64>", "mimeType": "..."}}``
- ``{"type": "document", "source": {"type": "data", "value": "<b64>", "mimeType": "..."}}``
- ``{"type": "binary", "mimeType": "...", "data": "<b64>"}`` (legacy)
- ``{"type": "image_url", "image_url": {"url": "data:..."}}`` (already OpenAI shape)
- bare strings (treated as text).
- Pydantic model instances (e.g. ``TextInputContent``, ``ImageInputContent``,
``DocumentInputContent`` from ``ag_ui.core``) — converted to dicts via
``model_dump()`` so the rest of the function can use ``.get()``.
"""
if isinstance(part, str):
if not part:
return None
return {"type": "text", "text": part}
# Pydantic model instances (from ag_ui.core deserialization) are not
# dicts but expose model_dump(). Convert once so the rest of the
# function can use dict-style .get() access uniformly.
if not isinstance(part, dict):
if hasattr(part, "model_dump"):
part = part.model_dump(by_alias=True)
else:
return None
ptype = part.get("type")
if ptype == "text":
text = part.get("text")
if isinstance(text, str) and text:
return {"type": "text", "text": text}
return None
if ptype == "image_url":
# Already OpenAI shape — pass through after light validation.
image_url = part.get("image_url")
if isinstance(image_url, str) and image_url:
return {"type": "image_url", "image_url": {"url": image_url}}
if isinstance(image_url, dict):
url = image_url.get("url")
if isinstance(url, str) and url:
return {"type": "image_url", "image_url": {"url": url}}
return None
if ptype in ("image", "document"):
source = part.get("source")
if not isinstance(source, dict):
return None
if source.get("type") != "data":
# url-based parts not supported by this agent path today —
# the demo only emits inline base64.
return None
value = source.get("value")
mime = source.get("mimeType")
if not isinstance(value, str) or not isinstance(mime, str):
return None
if ptype == "image" or mime.startswith("image/"):
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{value}"},
}
if "pdf" in mime.lower():
text = _extract_pdf_text(value)
if not text:
return {
"type": "text",
"text": "[Attached document: PDF could not be read.]",
}
return {"type": "text", "text": f"[Attached document]\n{text}"}
return None
if ptype == "binary":
mime = part.get("mimeType") or ""
data = part.get("data")
if not isinstance(mime, str) or not isinstance(data, str):
return None
if mime.startswith("image/"):
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{data}"},
}
if "pdf" in mime.lower():
text = _extract_pdf_text(data)
if not text:
return {
"type": "text",
"text": "[Attached document: PDF could not be read.]",
}
return {"type": "text", "text": f"[Attached document]\n{text}"}
return None
return None
def _build_user_content(content: Any) -> Any:
"""Translate user-message content into an OpenAI-compatible payload.
Returns either a raw string (when there's only one text part — a
common single-message case) or the list-of-parts shape that gpt-4o
expects for multimodal turns.
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
parts: list[dict[str, Any]] = []
for raw in content:
normalized = _normalize_part(raw)
if normalized is not None:
parts.append(normalized)
if not parts:
return ""
if len(parts) == 1 and parts[0].get("type") == "text":
return parts[0].get("text") or ""
return parts
def _build_messages(messages: Any, system_prompt: str) -> list[dict[str, Any]]:
"""Build the OpenAI messages list, preserving multimodal user parts."""
out: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
if not messages:
return out
for msg in messages:
role = (
getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
)
if not isinstance(role, str):
continue
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
if role == "user":
built = _build_user_content(content)
if built:
out.append({"role": "user", "content": built})
elif role == "assistant":
if isinstance(content, str) and content:
out.append({"role": "assistant", "content": content})
elif role == "system":
if isinstance(content, str) and content:
out.append({"role": "system", "content": content})
return out
# ---------------------------------------------------------------------------
# SSE handler
# ---------------------------------------------------------------------------
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/multimodal`` SSE handler — vision-capable streaming."""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("multimodal: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("multimodal: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
oai_messages = _build_messages(run_input.messages, _SYSTEM_PROMPT)
# Force a vision-capable model. We deliberately ignore LANGROID_MODEL
# here — the unified text-only agents are configured with cheaper
# models, and this demo's whole point is the vision path.
model = os.getenv("MULTIMODAL_MODEL", "gpt-4o")
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
message_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=message_id
)
)
try:
client = openai.AsyncOpenAI()
stream = await client.chat.completions.create(
model=model,
messages=oai_messages,
stream=True,
)
async for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
text = getattr(delta, "content", None)
if text:
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=message_id,
delta=text,
)
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("multimodal: OpenAI streaming call failed")
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END, message_id=message_id
)
)
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,337 @@
"""Langroid 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/ag2/src/agents/reasoning_agent.py` plus its
`/reasoning` server mount in `ag2/src/agent_server.py`, adapted to Langroid.
Why a custom route instead of the stock unified adapter
-------------------------------------------------------
Langroid's stock showcase adapter (`agents/agui_adapter.py`) calls the OpenAI
chat-completions API NON-streaming and reads only `message.content` /
`message.tool_calls` from the response — 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 mounts a small custom `/reasoning` sub-app (mirroring ag2'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
ag2'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
langroid-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 ag2'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 langroid-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. The stock langroid adapter 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
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 "/" — the HttpAgent posts to ``/reasoning/`` so the outer
# Mount strips ``/reasoning`` and this inner Mount at ``/`` resolves the
# endpoint. NEVER use Route("", ...) — an empty-path Route crashes Starlette
# at import time.
reasoning_app = FastAPI(title="Langroid Reasoning Agent")
reasoning_app.mount("/", ReasoningEndpoint)
@@ -0,0 +1,590 @@
"""Shared State (Read + Write) demo — Langroid.
Mirrors langgraph-python/src/agents/shared_state_read_write.py: full
bidirectional shared-state pattern between UI and agent.
- **UI -> agent (write)**: the UI owns a ``preferences`` object (name,
tone, language, interests) and writes it into agent state via
``agent.setState(...)`` from the React side. Every turn we read those
preferences out of ``RunAgentInput.state`` and prepend a system message
describing them, so the LLM adapts its response.
- **agent -> UI (read)**: the agent calls a ``set_notes`` tool to replace
the ``notes`` slice of shared state. The UI subscribes via ``useAgent``
and re-renders.
Langroid does not provide a native shared-state channel — we implement
it directly on top of AG-UI's ``STATE_SNAPSHOT`` event by emitting a
fresh snapshot whenever the agent mutates state.
The handler is wired up by ``agent_server.py`` at ``POST
/shared-state-read-write``.
LLM calls use the OpenAI client directly (not langroid's agent
abstraction) so that aimock can intercept and fixture-match requests by
message history shape (including ``hasToolResult`` matching on
``role: "tool"`` messages in the follow-up turn). The tool definition
for ``set_notes`` is passed as an OpenAI-format tool spec.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
import openai
logger = logging.getLogger(__name__)
# =====================================================================
# State shape (mirrors the UI's RWAgentState)
# =====================================================================
#
# {
# "preferences": { "name", "tone", "language", "interests": [...] },
# "notes": [str, ...]
# }
#
# `preferences` is owned by the UI. The agent only READS it.
# `notes` is owned by the agent. The agent calls `set_notes` to replace
# the array; the UI re-renders from shared state.
_VALID_TONES = frozenset({"formal", "casual", "playful"})
def _normalize_state(raw: Any) -> dict[str, Any]:
"""Coerce the inbound RunAgentInput.state into our canonical dict.
AG-UI types ``state`` as ``Any``, so a malformed frontend (or a
test fixture) could ship anything from ``None`` to a list. Anything
that isn't a dict is treated as "no state" — we don't try to recover
structure from it.
"""
if not isinstance(raw, dict):
return {"preferences": {}, "notes": []}
prefs = raw.get("preferences") if isinstance(raw.get("preferences"), dict) else {}
notes_raw = raw.get("notes")
notes = (
[n for n in notes_raw if isinstance(n, str)]
if isinstance(notes_raw, list)
else []
)
return {"preferences": prefs, "notes": notes}
def build_preferences_system_message(prefs: dict[str, Any]) -> str | None:
"""Render the UI-supplied preferences into a system-message string.
Returns ``None`` when no preference is set so the caller can skip
injection cleanly. Tone is sanitized against a closed set; unknown
values are silently dropped (matches the agent-config demo's
posture: a frontend bug should not 500 a turn).
"""
if not prefs:
return None
lines: list[str] = ["The user has shared these preferences with you:"]
name = prefs.get("name")
if isinstance(name, str) and name:
lines.append(f"- Name: {name}")
tone = prefs.get("tone")
if isinstance(tone, str) and tone in _VALID_TONES:
lines.append(f"- Preferred tone: {tone}")
language = prefs.get("language")
if isinstance(language, str) and language:
lines.append(f"- Preferred language: {language}")
interests = prefs.get("interests")
if isinstance(interests, list):
items = [i for i in interests if isinstance(i, str) and i]
if items:
lines.append(f"- Interests: {', '.join(items)}")
if len(lines) == 1:
# No usable keys — caller can skip injection.
return None
lines.append(
"Tailor every response to these preferences. Address the user "
"by name when appropriate."
)
return "\n".join(lines)
# =====================================================================
# `set_notes` tool — OpenAI function spec for the tool the agent uses
# to write the notes slice of shared state.
# =====================================================================
_SET_NOTES_TOOL_SPEC: dict[str, Any] = {
"type": "function",
"function": {
"name": "set_notes",
"description": (
"Replace the notes array in shared state with the FULL updated "
"list of short note strings (existing notes + any new ones). Use "
"whenever the user asks you to remember something, or when you "
"observe something worth surfacing in the UI's notes panel. Keep "
"each note short (< 120 chars)."
),
"parameters": {
"type": "object",
"properties": {
"notes": {
"type": "array",
"items": {"type": "string"},
"description": (
"The complete list of notes after the update. Always "
"include every previously-recorded note you want to "
"keep — this REPLACES the array."
),
},
},
"required": ["notes"],
},
},
}
_SYSTEM_PROMPT = (
"You are a helpful, concise assistant. The user's preferences are "
"supplied via shared state and will be added as a system message at "
"the start of every turn — always respect them.\n\n"
"When the user asks you to remember something, or when you observe "
"something worth surfacing in the UI's notes panel, call the "
"`set_notes` tool with the FULL updated list of short note strings "
"(existing notes + any new ones). NEVER pass a partial diff — always "
"the complete list.\n\n"
"Keep your prose replies brief — 1-2 sentences."
)
async def _call_openai(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> Any:
"""Call the OpenAI chat completions API directly.
Uses ``openai.AsyncOpenAI()`` which reads ``OPENAI_API_KEY`` and
``OPENAI_BASE_URL`` from the environment (aimock sets the base URL
in the showcase). Returns the first choice's message object.
When ``tools`` is None or empty, omits the tools parameter so the
follow-up call (no tool needed) doesn't confuse the model into
re-calling tools.
"""
model = os.getenv("LANGROID_MODEL", "gpt-4.1")
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model=model,
messages=messages,
tools=tools if tools else openai.NOT_GIVEN,
)
return response.choices[0].message
# =====================================================================
# AG-UI SSE handler
# =====================================================================
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _agui_messages_to_openai(
messages: Any,
system_prompt: str,
) -> list[dict[str, Any]]:
"""Convert AG-UI messages to OpenAI chat completion format.
Preserves structured fields (tool_calls, tool_call_id) so aimock's
``hasToolResult`` fixture matcher can detect ``role: "tool"`` messages
in follow-up turns. Mirrors ``agui_adapter._agui_messages_to_openai``.
"""
oai_msgs: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
]
if not messages:
return oai_msgs
for msg in messages:
role = getattr(msg, "role", None)
if not isinstance(role, str):
if isinstance(msg, dict):
role = msg.get("role")
if not isinstance(role, str):
continue
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None)
if isinstance(msg, dict):
tool_call_id = tool_call_id or msg.get("tool_call_id")
content = getattr(msg, "content", "") or ""
if isinstance(msg, dict):
content = content or msg.get("content", "")
if tool_call_id:
oai_msgs.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content),
}
)
continue
if role == "assistant":
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
tool_calls_raw = getattr(msg, "tool_calls", None)
if isinstance(msg, dict):
tool_calls_raw = tool_calls_raw or msg.get("tool_calls")
oai_msg: dict[str, Any] = {"role": "assistant"}
if content:
oai_msg["content"] = str(content)
if tool_calls_raw:
oai_tcs = []
for tc in tool_calls_raw:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
if fn is None and isinstance(tc, dict):
fn_name = tc.get("function", {}).get("name", "")
fn_args = tc.get("function", {}).get("arguments", "")
tc_id = tc_id or tc.get("id", "")
else:
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
oai_tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if oai_tcs:
oai_msg["tool_calls"] = oai_tcs
if "content" not in oai_msg:
oai_msg["content"] = None
else:
if "content" not in oai_msg:
oai_msg["content"] = ""
oai_msgs.append(oai_msg)
continue
if role in ("user", "system", "developer"):
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
if content is not None:
oai_msgs.append(
{
"role": role,
"content": str(content),
}
)
continue
return oai_msgs
def _extract_set_notes_args(response: Any) -> tuple[list[str] | None, str | None]:
"""Pull a ``set_notes`` tool call out of an OpenAI ChatCompletionMessage.
Returns ``(notes, tool_call_id)`` when the response contains a
``set_notes`` call; returns ``(None, None)`` otherwise so the caller
can fall through to plain-text streaming. The ``tool_call_id`` is
needed to build the follow-up ``role: "tool"`` result message.
"""
tool_calls = getattr(response, "tool_calls", None) or []
for tc in tool_calls:
fn = getattr(tc, "function", None)
name = getattr(fn, "name", None) if fn is not None else None
if name != "set_notes":
continue
tc_id = getattr(tc, "id", None)
raw_args = getattr(fn, "arguments", None) if fn is not None else None
args: Any = raw_args
if isinstance(raw_args, (str, bytes, bytearray)):
try:
args = json.loads(raw_args)
except (ValueError, TypeError):
return None, None
if isinstance(args, dict):
notes = args.get("notes")
if isinstance(notes, list):
return [n for n in notes if isinstance(n, str)], tc_id
return None, None
async def handle_run(request: Request) -> StreamingResponse:
"""Handle one AG-UI ``/shared-state-read-write`` request.
Uses the OpenAI client directly (not langroid's agent abstraction)
so that aimock can fixture-match requests by full message history,
including ``hasToolResult`` matching on ``role: "tool"`` messages
in the follow-up turn after a ``set_notes`` tool call.
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception(
"shared-state-read-write: failed to parse body (error_id=%s)",
error_id,
)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except Exception as exc: # noqa: BLE001 — pydantic.ValidationError is fine here
logger.exception(
"shared-state-read-write: invalid RunAgentInput (error_id=%s)",
error_id,
)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
state = _normalize_state(run_input.state)
prefs_msg = build_preferences_system_message(state.get("preferences") or {})
system_message = _SYSTEM_PROMPT
if prefs_msg is not None:
system_message = f"{_SYSTEM_PROMPT}\n\n{prefs_msg}"
# Build OpenAI-format messages from the AG-UI message history.
oai_messages = _agui_messages_to_openai(run_input.messages or [], system_message)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
# Echo the inbound state back as the initial snapshot so the UI's
# subscription always has a known-good baseline (and so a fresh
# session sees the empty `notes` array even before the agent
# writes one).
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
try:
response = await _call_openai(oai_messages, [_SET_NOTES_TOOL_SPEC])
except Exception as exc: # noqa: BLE001 — surface as RunError + finish
logger.exception("shared-state-read-write: _call_openai failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
if response is None:
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
new_notes, oai_tool_call_id = _extract_set_notes_args(response)
if new_notes is not None:
# The agent decided to update the notes array. Apply, then
# ack via tool-call events + a fresh STATE_SNAPSHOT so the
# UI re-renders.
state["notes"] = new_notes
tool_call_id = oai_tool_call_id or str(uuid.uuid4())
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=tool_call_id,
tool_call_name="set_notes",
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=tool_call_id,
delta=json.dumps({"notes": new_notes}),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=tool_call_id,
)
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
# Build the follow-up message array with the tool result
# appended, so aimock can match it with hasToolResult: true.
# This mirrors LangGraph's tool execution loop: the assistant
# message (with tool_calls) + the tool result message go back
# to the LLM for the natural-language acknowledgement.
raw_args = (
getattr(
getattr(response.tool_calls[0], "function", None), "arguments", "{}"
)
if response.tool_calls
else "{}"
)
follow_up_messages = oai_messages + [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": tool_call_id,
"type": "function",
"function": {
"name": "set_notes",
"arguments": raw_args
if isinstance(raw_args, str)
else json.dumps(raw_args),
},
}
],
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": "Notes updated.",
},
]
# Follow-up call WITHOUT tools — we don't want the model to
# re-call set_notes in the acknowledgement turn.
try:
follow_up = await _call_openai(follow_up_messages)
except Exception: # noqa: BLE001
logger.exception(
"shared-state-read-write: follow-up _call_openai failed"
)
follow_up = None
if follow_up is not None:
content = getattr(follow_up, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=msg_id,
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=msg_id,
)
)
else:
content = getattr(response, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=msg_id
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END, message_id=msg_id
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,621 @@
"""Sub-Agents demo — Langroid.
Mirrors langgraph-python/src/agents/subagents.py and the google-adk
subagents_agent.py: a top-level "supervisor" LLM orchestrates three
specialized sub-agents, exposed as tools:
- ``research_agent`` — gathers facts (3-5 bullets)
- ``writing_agent`` — drafts a polished paragraph
- ``critique_agent`` — reviews a draft and gives 2-3 critiques
Each sub-agent is its own ``langroid.ChatAgent`` with a single-task
system prompt and no tools. The supervisor delegates by emitting a tool
call against one of the three names; the SSE adapter intercepts the
call, runs the matching sub-agent synchronously, records a Delegation
entry into shared state (``running`` -> ``completed`` / ``failed``),
emits a ``STATE_SNAPSHOT`` so the UI re-renders, and then re-prompts
the supervisor with the sub-agent's output so it can chain (research
-> write -> critique) or summarize.
The handler is wired up by ``agent_server.py`` at ``POST /subagents``.
"""
# @region[supervisor-delegation-tools]
# @region[subagent-setup]
from __future__ import annotations
import functools
import json
import logging
import os
import uuid
from typing import Annotated, Any, AsyncGenerator, Literal, TypedDict
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
import langroid as lr
import langroid.language_models as lm
from langroid.agent.tool_message import ToolMessage
logger = logging.getLogger(__name__)
# =====================================================================
# Shared state shape
# =====================================================================
class Delegation(TypedDict):
id: str
sub_agent: Literal["research_agent", "writing_agent", "critique_agent"]
task: str
status: Literal["running", "completed", "failed"]
result: str
# =====================================================================
# Sub-agent system prompts (single-task, no tools)
# =====================================================================
# In Langroid, each sub-agent is a `lr.ChatAgent` with a single-task
# `system_message` and no tools. The supervisor only ever sees the
# sub-agent's final-message content — no shared memory, no shared tools.
_RESEARCH_SYSTEM = (
"You are a research sub-agent. Given a topic, produce a concise "
"bulleted list of 3-5 key facts. No preamble, no closing."
)
_WRITING_SYSTEM = (
"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."
)
_CRITIQUE_SYSTEM = (
"You are an editorial critique sub-agent. Given a draft, give 2-3 "
"crisp, actionable critiques. No preamble."
)
_SUB_PROMPTS: dict[str, str] = {
"research_agent": _RESEARCH_SYSTEM,
"writing_agent": _WRITING_SYSTEM,
"critique_agent": _CRITIQUE_SYSTEM,
}
def _resolve_sub_model() -> str:
"""Resolve the sub-agent model.
Mirrors ``_resolve_a2ui_model`` in ``agents.agent``: bare model name
(langroid passes the string literally to the OpenAI SDK, which
rejects ``openai/gpt-4.1`` as "model not found").
"""
return os.getenv("SUBAGENT_MODEL") or os.getenv("LANGROID_MODEL") or "gpt-4.1"
@functools.lru_cache(maxsize=8)
def _build_sub_llm_config(name: str) -> lm.OpenAIGPTConfig:
"""Build (and memoize) the immutable ``OpenAIGPTConfig`` for one sub-agent.
Only the LLM config — which is stateless and credential-bearing — is
cached. The ``ChatAgent`` itself is rebuilt per call (see
``_build_sub_agent``) because ``lr.ChatAgent`` accumulates
``message_history`` across ``llm_response`` / ``llm_response_async``
calls and must NOT be shared across concurrent requests.
"""
# ``name`` participates in the cache key indirectly via per-name
# callsites; the config itself is identical across sub-agents today
# but keeping the parameter makes the cache robust if a future
# refactor varies model/temperature per sub-agent.
del name # currently unused — kept for cache-key shape stability
model = _resolve_sub_model()
return lm.OpenAIGPTConfig(
chat_model=model,
# Sub-agents are single-shot — non-streaming keeps the supervisor
# turn deterministic (we want the full result before recording
# the delegation as completed).
stream=False,
)
def _build_sub_agent(name: str) -> lr.ChatAgent:
"""Build a fresh ``ChatAgent`` for one sub-agent invocation.
A new agent is constructed on every call. Caching the agent
instance (e.g. via ``lru_cache``) would be unsafe: ``lr.ChatAgent``
accumulates ``message_history`` across ``llm_response_async`` calls,
so two concurrent users invoking the same sub-agent would
cross-contaminate each other's conversation history and grow the
token budget unboundedly across the process lifetime.
The immutable LLM config is cached separately (see
``_build_sub_llm_config``) so we don't pay credential-resolution
overhead per call.
"""
system_prompt = _SUB_PROMPTS[name]
llm_config = _build_sub_llm_config(name)
agent_config = lr.ChatAgentConfig(
llm=llm_config,
system_message=system_prompt,
)
return lr.ChatAgent(agent_config)
# @endregion[subagent-setup]
async def _invoke_sub_agent(name: str, task: str) -> str:
"""Run a sub-agent on ``task`` and return its final-message content.
Uses ``llm_response_async`` so the SSE writer stays cooperative —
a synchronous ``sub.llm_response(task)`` would block the event loop
for the entire LLM round-trip and stall any other concurrent SSE
responses sharing this worker.
Raises ``RuntimeError`` (with the exception class chained via
``__cause__``) on transport / SDK failures so the caller can record
a ``failed`` delegation. The original exception is preserved
server-side via ``logger.exception``.
"""
sub = _build_sub_agent(name)
try:
response = await sub.llm_response_async(task)
except Exception as exc: # noqa: BLE001 — see docstring
logger.exception("subagent %s call failed", name)
# Match the google-adk surface: only the class name leaks; the
# full traceback stays in server logs.
raise RuntimeError(
f"sub-agent call failed: {exc.__class__.__name__} "
"(see server logs for details)"
) from exc
if response is None:
raise RuntimeError("sub-agent returned no response")
content = getattr(response, "content", None) or ""
if not content:
raise RuntimeError("sub-agent returned empty content")
return content
# =====================================================================
# Supervisor tools (langroid ToolMessage subclasses)
# =====================================================================
# In Langroid, the supervisor delegates by emitting a tool call against
# one of these `ToolMessage` subclasses. The SSE adapter intercepts the
# call (rather than letting Langroid dispatch to `.handle`), runs the
# matching sub-agent, records a `Delegation` into shared state, and
# returns the sub-agent's output as the tool result.
class _SubAgentTool(ToolMessage):
"""Base class for the three supervisor delegation tools.
The actual sub-agent invocation happens in the SSE adapter (so we
can record delegations into shared state); this ``handle`` is a
placeholder that's never called in the normal flow — we intercept
the tool call before langroid would dispatch to it. Logging here
matches the frontend-tool pattern in ``agents.agent``.
"""
request: str = "_subagent_base" # overridden
purpose: str = "" # overridden
task: Annotated[
str,
"The exact task for the sub-agent. Pass relevant facts/draft "
"from prior delegations through this string.",
]
def handle(self) -> str:
logger.error(
"%s.handle fired server-side — adapter dispatch regression; "
"the supervisor sub-agent tool was not intercepted",
self.__class__.__name__,
)
return f"{self.request} dispatched"
class ResearchAgentTool(_SubAgentTool):
request: str = "research_agent"
purpose: str = (
"Delegate a research task to the research sub-agent. Use for: "
"gathering facts, background, definitions, statistics. Returns a "
"bulleted list of key facts."
)
class WritingAgentTool(_SubAgentTool):
request: str = "writing_agent"
purpose: str = (
"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`."
)
class CritiqueAgentTool(_SubAgentTool):
request: str = "critique_agent"
purpose: str = (
"Delegate a critique task to the critique sub-agent. Use for: "
"reviewing a draft and suggesting concrete improvements."
)
_SUPERVISOR_TOOLS: tuple[type[ToolMessage], ...] = (
ResearchAgentTool,
WritingAgentTool,
CritiqueAgentTool,
)
# @endregion[supervisor-delegation-tools]
_SUB_AGENT_NAMES: frozenset[str] = frozenset(
t.default_value("request") for t in _SUPERVISOR_TOOLS
)
_SUPERVISOR_PROMPT = (
"You are a supervisor agent that coordinates three specialized "
"sub-agents to produce high-quality deliverables.\n\n"
"Available sub-agents (call them as tools):\n"
" - research_agent: gathers facts on a topic.\n"
" - writing_agent: turns facts + a brief into a polished draft.\n"
" - critique_agent: reviews a draft and suggests improvements.\n\n"
"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, including the in-flight `running` state."
)
def _create_supervisor() -> lr.ChatAgent:
model = os.getenv("LANGROID_MODEL", "gpt-4.1")
llm_config = lm.OpenAIGPTConfig(chat_model=model, stream=False)
agent_config = lr.ChatAgentConfig(llm=llm_config, system_message=_SUPERVISOR_PROMPT)
agent = lr.ChatAgent(agent_config)
agent.enable_message(list(_SUPERVISOR_TOOLS))
return agent
# =====================================================================
# AG-UI SSE handler
# =====================================================================
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _normalize_state(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {"delegations": []}
delegations = raw.get("delegations")
if not isinstance(delegations, list):
delegations = []
return {"delegations": list(delegations)}
def _build_conversation(messages: Any) -> str:
parts: list[str] = []
if not messages:
return ""
for msg in messages:
role = (
getattr(msg, "role", None)
if hasattr(msg, "role")
else (msg.get("role") if isinstance(msg, dict) else None)
)
content = (
getattr(msg, "content", None)
if hasattr(msg, "content")
else (msg.get("content") if isinstance(msg, dict) else None)
)
if isinstance(role, str) and isinstance(content, str):
parts.append(f"{role}: {content}")
return "\n".join(parts)
def _extract_sub_agent_calls(response: Any) -> list[tuple[str, str, str]]:
"""Pull supervisor sub-agent tool calls out of an LLMResponse.
Returns a list of (call_id, sub_agent_name, task). Skips any call
that doesn't target one of the three sub-agents or that has a
malformed ``task`` argument.
"""
out: list[tuple[str, str, str]] = []
tool_calls = getattr(response, "oai_tool_calls", None) or []
for tc in tool_calls:
fn = getattr(tc, "function", None)
name = getattr(fn, "name", None) if fn is not None else None
if name not in _SUB_AGENT_NAMES:
continue
raw_args = getattr(fn, "arguments", None) if fn is not None else None
args: Any = raw_args
if isinstance(raw_args, (str, bytes, bytearray)):
try:
args = json.loads(raw_args)
except (ValueError, TypeError):
continue
if not isinstance(args, dict):
continue
task = args.get("task")
if not isinstance(task, str) or not task:
continue
call_id = getattr(tc, "id", None) or str(uuid.uuid4())
out.append((call_id, name, task))
return out
def _append_delegation(state: dict[str, Any], *, sub_agent: str, task: str) -> str:
entry_id = str(uuid.uuid4())
entry: Delegation = {
"id": entry_id,
"sub_agent": sub_agent, # type: ignore[typeddict-item]
"task": task,
"status": "running",
"result": "",
}
state["delegations"] = [*state.get("delegations", []), entry]
return entry_id
def _update_delegation(
state: dict[str, Any], *, entry_id: str, status: str, result: str
) -> None:
delegations = list(state.get("delegations") or [])
for entry in delegations:
if entry.get("id") == entry_id:
entry["status"] = status
entry["result"] = result
state["delegations"] = delegations
return
logger.warning(
"subagents: delegation entry %s missing on update — final %s state "
"(result_length=%d) will not be rendered",
entry_id,
status,
len(result),
)
# Maximum number of supervisor turns per request. Belt-and-suspenders:
# the prompt already nudges the supervisor to delegate sequentially and
# return a summary, but a stuck loop (model keeps re-delegating without
# converging) would otherwise burn quota indefinitely.
_MAX_SUPERVISOR_TURNS = 6
async def handle_run(request: Request) -> StreamingResponse:
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("subagents: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except Exception as exc: # noqa: BLE001
logger.exception("subagents: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
state = _normalize_state(run_input.state)
supervisor = _create_supervisor()
user_message = _build_conversation(run_input.messages)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
prompt = user_message
for turn in range(_MAX_SUPERVISOR_TURNS):
try:
response = await supervisor.llm_response_async(prompt)
except Exception as exc: # noqa: BLE001
logger.exception(
"subagents: supervisor.llm_response_async failed (turn=%d)",
turn,
)
# Use RunErrorEvent (the proper AG-UI primitive) so the UI
# can surface a real error state instead of rendering a raw
# JSON blob inside a chat bubble.
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Supervisor failed: {exc.__class__.__name__}",
)
)
break
if response is None:
break
calls = _extract_sub_agent_calls(response)
if not calls:
# Plain text — supervisor finished, stream and stop.
content = getattr(response, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=msg_id,
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=msg_id,
)
)
break
# Run each delegation in order. We stream a `running` snapshot
# before invoking the sub-agent and a `completed` / `failed`
# snapshot after, so the UI shows the in-flight row.
results: list[str] = []
for call_id, sub_name, task in calls:
# Emit the supervisor's tool call first — useful for any UI
# that wants to render the call envelope itself.
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=call_id,
tool_call_name=sub_name,
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=call_id,
delta=json.dumps({"task": task}),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=call_id,
)
)
entry_id = _append_delegation(state, sub_agent=sub_name, task=task)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
try:
sub_result = await _invoke_sub_agent(sub_name, task)
except RuntimeError as exc:
_update_delegation(
state,
entry_id=entry_id,
status="failed",
result=str(exc),
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
results.append(f"[{sub_name} failed: {exc}]")
continue
_update_delegation(
state,
entry_id=entry_id,
status="completed",
result=sub_result,
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
results.append(f"[{sub_name} result]\n{sub_result}")
# Re-prompt the supervisor with all delegation outputs so it
# can chain (research -> write -> critique) or summarize.
prompt = (
"The sub-agents you delegated to returned the following:\n\n"
+ "\n\n".join(results)
+ "\n\nDecide whether to delegate further or, if the work "
"is done, write a brief final summary for the user."
)
else:
# Loop finished without ``break`` — we hit the turn cap.
msg_id = str(uuid.uuid4())
cap_msg = (
"Supervisor reached the delegation cap "
f"({_MAX_SUPERVISOR_TURNS} turns) without finalizing. "
"Showing partial results; please refine your request."
)
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=msg_id
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=cap_msg,
)
)
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=msg_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,54 @@
// Dedicated runtime for the A2UI — Fixed Schema cell (Langroid).
//
// Splitting into its own endpoint lets us set `a2ui.injectA2UITool: false` —
// the backend Langroid agent owns the `display_flight` tool which emits its
// own `a2ui_operations` container directly in the tool result.
//
// References:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
// - showcase/integrations/ag2/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
// - src/agents/a2ui_fixed_agent.py (the Langroid 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
// the `display_flight` tool result (see src/agents/a2ui_fixed_agent.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,169 @@
// Dedicated runtime for the Agent Config Object demo (Langroid).
//
// The <CopilotKit properties={...}> provider forwards tone / expertise /
// responseLength on every run; the V1 Next.js runtime propagates those as
// top-level keys on the AG-UI `forwardedProps` payload.
//
// Upstream parity (see langgraph-python route for the canonical logic):
// the frontend's provider properties arrive flat on `forwardedProps`
// (e.g. `forwardedProps.tone`). The LangGraph showcase's Python graph
// reads them from `RunnableConfig.configurable.properties`, so the TS
// adapter there repacks flat keys into
// `forwardedProps.config.configurable.properties` before dispatching.
//
// Langroid's backend does not have a LangGraph RunnableConfig, but we
// mirror the same payload shape here so:
// 1. The frontend contract is identical across all showcases.
// 2. The Langroid Python backend can read the repacked location
// (`run_input.forwarded_props.config.configurable.properties`)
// deterministically — top-level flat keys would collide with any
// future AG-UI additions to `forwardedProps`.
//
// We subclass `HttpAgent` and override `requestInit` (the only place in
// the AG-UI client that serializes the body) so the repack happens once
// per request with no middleware plumbing.
//
// Scoped to its own endpoint so non-demo cells don't pay the cost of
// this repack and so the Playwright spec can assert request-body
// propagation against exactly one URL.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Reserved AG-UI / LangGraph stream-payload keys that MUST NOT be repacked
// under `configurable.properties`. Anything outside this set is treated as
// user-supplied frontend state (tone / expertise / responseLength / ...) and
// moved into `forwardedProps.config.configurable.properties`.
//
// Keep this list in sync with the upstream canonical implementation:
// `showcase/integrations/langgraph-python/src/app/api/copilotkit-agent-config/route.ts`.
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",
]);
type RunInputWithForwardedProps = RunAgentInput & {
forwardedProps?: Record<string, unknown>;
};
function repackForwardedPropsIntoConfigurable<
T extends RunInputWithForwardedProps,
>(input: T): T {
const fp = (input.forwardedProps ?? {}) as Record<string, unknown>;
if (!fp || typeof fp !== "object") return input;
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;
}
/**
* `HttpAgent` subclass that repacks provider `properties` (flat top-level
* keys on `forwardedProps`) into `forwardedProps.config.configurable.properties`
* before the body is serialized and POSTed to the Langroid Python backend.
*
* `requestInit` is the single place in the AG-UI client where the payload
* is serialized (`body: JSON.stringify(input)`), so overriding it here is
* the minimum-surface hook — no middleware plumbing, no clone semantics
* to preserve.
*/
class AgentConfigHttpAgent extends HttpAgent {
protected requestInit(input: RunAgentInput): RequestInit {
const repacked = repackForwardedPropsIntoConfigurable(
input as RunInputWithForwardedProps,
);
return super.requestInit(repacked as RunAgentInput);
}
}
const agentConfigAgent = new AgentConfigHttpAgent({ url: `${AGENT_URL}/` });
const agents = {
"agent-config-demo": agentConfigAgent,
// Internal components calling useAgent() with no args default to "default".
default: agentConfigAgent,
};
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records.
agents,
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
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 /demos/auth cell (Langroid).
//
// Framework-native request authentication via the V2 runtime's `onRequest`
// hook. Validates a static `Authorization: Bearer <DEMO_TOKEN>` header;
// mismatch throws 401 before the request reaches the agent.
//
// Uses `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly
// so the `hooks.onRequest` option is honored (the V1 Next.js adapter does
// not forward the `hooks` option).
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 unified Langroid agent — this demo shows the gate, not bespoke
// auth-aware agent behavior.
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>>
agents: {
"auth-demo": authDemoAgent,
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }: { request: 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);
@@ -0,0 +1,74 @@
// Dedicated runtime for the Beautiful Chat flagship showcase cell (Langroid).
//
// Beautiful Chat exercises A2UI (dynamic + fixed schema) and Open
// Generative UI. The Langroid backend exposes a single unified agent on
// "/" that handles every request, so the cell routes there; the flagship
// behavior comes from the runtime flags below plus the frontend's per-cell
// registrations.
//
// Isolated on its own endpoint (mirroring beautiful-chat in the canonical)
// because the `openGenerativeUI` / `a2ui` runtime flags set global state on
// the probe response that would otherwise leak into the other cells sharing
// the default `/api/copilotkit` runtime.
//
// Mirrors showcase/integrations/pydantic-ai/src/app/api/copilotkit-beautiful-chat/route.ts.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent } from "@ag-ui/client";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// The beautiful-chat page resolves <CopilotKit agent="beautiful-chat">
// here; internal components (headless-chat, example-canvas) also call
// `useAgent()` with no args, which defaults to agentId "default". Alias
// default to the same backend so those hooks resolve.
const agents: Record<string, AbstractAgent> = {
"beautiful-chat": new HttpAgent({ url: `${AGENT_URL}/` }),
default: new HttpAgent({ url: `${AGENT_URL}/` }),
};
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: true,
a2ui: {
// The targeted unified Langroid agent ("/") already registers the
// `generate_a2ui` tool itself (GenerateA2UITool in ALL_TOOLS, enabled
// via create_agent), so the runtime must NOT inject a second copy —
// that would double-bind the render tool. Matches pydantic-ai's
// beautiful-chat (this file's mirror source).
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",
},
});
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 };
console.error(
`[copilotkit-beautiful-chat/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,47 @@
// Dedicated runtime for the byoc-hashbrown demo (Langroid).
//
// The demo page wraps CopilotChat in HashBrownDashboard 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 is the FastAPI handler at
// `${AGENT_URL}/byoc-hashbrown` whose system prompt is tuned to emit the
// hashbrown JSON envelope (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
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,46 @@
/**
* Dedicated runtime for the BYOC json-render demo (Langroid).
*
* Mirrors the hashbrown route. The agent at `${AGENT_URL}/byoc-json-render`
* emits a flat element-map spec that the frontend's `<Renderer />` (from
* `@json-render/react`) renders against a Zod-validated catalog.
*/
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
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,52 @@
// Dedicated runtime for the Declarative Generative UI (A2UI) cell (Langroid).
//
// The unified Langroid agent already owns a `generate_a2ui` tool (see
// src/agents/agent.py -> GenerateA2UITool). We route this demo here so we
// can set `a2ui.injectA2UITool: false` — the runtime must NOT auto-inject
// its own A2UI tool on top of the agent-owned one.
//
// The A2UI middleware still runs: it serialises the registered client
// catalog into the agent's context so the secondary LLM inside
// `generate_a2ui` knows which components to emit.
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 declarativeGenUiAgent = new HttpAgent({ url: `${AGENT_URL}/` });
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "declarative-gen-ui": declarativeGenUiAgent },
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: "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,54 @@
// Dedicated runtime for the declarative-hashbrown demo (Langroid).
//
// The demo page wraps CopilotChat in HashBrownDashboard 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 is the FastAPI handler at
// `${AGENT_URL}/byoc-hashbrown` whose system prompt is tuned to emit the
// hashbrown JSON envelope (see `src/agents/byoc_hashbrown_agent.py`). The
// demo folder + route + agent slug were renamed from `byoc-hashbrown` to the
// canonical `declarative-hashbrown` surface; the page mounts
// <CopilotKit agent="declarative-hashbrown-demo">.
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 declarativeHashbrownAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
"declarative-hashbrown-demo": declarativeHashbrownAgent,
default: declarativeHashbrownAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-hashbrown/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,52 @@
// Dedicated runtime for the declarative-json-render demo (Langroid).
//
// The demo page renders the agent's JSON output into a frontend-owned
// component catalog via @json-render/react. The agent behind this endpoint
// is the FastAPI handler at `${AGENT_URL}/byoc-json-render` (see
// `src/agents/byoc_json_render_agent.py`). The demo folder + route surface
// were renamed from `byoc-json-render` to the canonical
// `declarative-json-render`; the agent ID retains its legacy
// `byoc_json_render` name.
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
agents: {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-json-render/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,76 @@
// CopilotKit runtime for the MCP Apps cell (Langroid).
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware:
// 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` (registered by CopilotKit
// internally) renders inline in the chat as a sandboxed iframe.
//
// The agent itself is the no-tools FastAPI handler at
// `${AGENT_URL}/mcp-apps` — see `src/agents/mcp_apps_agent.py`. The
// runtime forwards the MCP tool catalog through `RunAgentInput.tools`,
// the agent forwards it to OpenAI, and any tool calls the model emits
// flow back through the middleware.
//
// Reference:
// https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
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` });
// headless-complete shares this runtime (its page wires
// runtimeUrl="/api/copilotkit-mcp-apps") but is backed by the unified
// Langroid agent on "/" — the same backend the main route registers it
// against.
const headlessCompleteAgent = new HttpAgent({ url: `${AGENT_URL}/` });
// @region[runtime-mcpapps-config]
const 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: {
"headless-complete": headlessCompleteAgent,
"mcp-apps": mcpAppsAgent,
default: mcpAppsAgent,
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Always 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,50 @@
// Dedicated runtime for the Multimodal Attachments demo (Langroid).
//
// Why its own route? The backing agent (`multimodal_agent.py`) runs a
// vision-capable model (gpt-4o). Every other cell in the showcase uses a
// text-only, cheaper model. Registering this agent under the shared
// `/api/copilotkit` runtime would silently upgrade *all* cells that share
// that runtime to a vision model whenever the browser routed to this one
// — wasting tokens and blurring the per-demo cost boundary.
//
// The page at `src/app/demos/multimodal/page.tsx` points its `runtimeUrl`
// here and sets `agent="multimodal-demo"`.
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 = {
// The page mounts <CopilotKit agent="multimodal-demo">.
"multimodal-demo": multimodalAgent,
// useAgent() with no args defaults to "default"; alias for safety.
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
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,54 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
// Dedicated runtime for the Open Generative UI demos (Langroid).
// 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.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const agents: Record<string, HttpAgent> = {
"open-gen-ui": new HttpAgent({ url: `${AGENT_URL}/` }),
"open-gen-ui-advanced": new HttpAgent({ url: `${AGENT_URL}/` }),
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// @region[minimal-runtime-flag]
// @region[advanced-runtime-config]
// 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[advanced-runtime-config]
// @endregion[minimal-runtime-flag]
});
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,65 @@
// Dedicated runtime for the Shared State (Read + Write) demo (Langroid).
//
// The unified Langroid agent at POST / does not consume RunAgentInput.state
// or emit STATE_SNAPSHOT events. The shared-state-read-write demo needs
// both — UI -> agent writes via agent.setState, agent -> UI writes via
// the `set_notes` tool — so we point this runtime at a dedicated FastAPI
// endpoint (POST /shared-state-read-write) that implements its own AG-UI
// SSE pipeline with full state support.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import crypto from "node:crypto";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const sharedStateReadWriteAgent = new HttpAgent({
url: `${AGENT_URL}/shared-state-read-write`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; same
// workaround as the main route.ts.
agents: {
"shared-state-read-write": sharedStateReadWriteAgent,
default: sharedStateReadWriteAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-shared-state-read-write",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
// Log full error details server-side (with a correlation id) but do
// NOT leak `error.message` / `error.stack` to the client — those can
// contain internal paths, library internals, or transient secrets.
// Operators correlate via `errorId` in logs.
const errorId = crypto.randomUUID();
const e = error instanceof Error ? error : new Error(String(error));
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
route: "/api/copilotkit-shared-state-read-write",
errorId,
message: e.message,
stack: e.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
@@ -0,0 +1,62 @@
// Dedicated runtime for the Sub-Agents demo (Langroid).
//
// Routes to POST /subagents on the FastAPI agent server, which runs a
// supervisor LLM that delegates to three specialized sub-agents
// (research / writing / critique). Each delegation is recorded into
// state["delegations"] and surfaced to the UI via STATE_SNAPSHOT events
// so the live delegation log can render running -> completed transitions.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import crypto from "node:crypto";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const subagentsAgent = new HttpAgent({
url: `${AGENT_URL}/subagents`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
subagents: subagentsAgent,
default: subagentsAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-subagents",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
// Log full error details server-side (with a correlation id) but do
// NOT leak `error.message` / `error.stack` to the client — those can
// contain internal paths, library internals, or transient secrets.
// Operators correlate via `errorId` in logs.
const errorId = crypto.randomUUID();
const e = error instanceof Error ? error : new Error(String(error));
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
route: "/api/copilotkit-subagents",
errorId,
message: e.message,
stack: e.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
@@ -0,0 +1,106 @@
// Dedicated runtime for the /demos/voice cell (Langroid).
//
// 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`), so recorded
// audio is transcribed and the transcript auto-sends.
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Implementation
// --------------
// Wires the **V2** `CopilotRuntime` directly (from `@copilotkit/runtime/v2`)
// because the V1 wrapper drops the `transcriptionService` option on the floor.
// V2 URL-routes on `/info`, `/agent/:id/run`, `/transcribe`, etc., so the
// route file lives at `[[...slug]]/route.ts` to catch every sub-path under
// `/api/copilotkit-voice`.
//
// The actual chat agent is the unified Langroid AG-UI backend that runs at
// `${AGENT_URL}/` (port 8000 by default). We register an `HttpAgent` against
// it under the "voice-demo" slug used by the page.
// @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}/` });
/**
* Transcription service wrapper that reports a clean, typed auth error when
* OPENAI_API_KEY is not configured. When the key is present we delegate to
* the real OpenAI-backed service.
*
* "api key" substring in the thrown error is matched by the V2 runtime's
* `handleTranscribe` and mapped to `AUTH_FAILED → HTTP 401`.
*/
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]
// Cache the runtime + handler across invocations so the transcription service
// is constructed once per Node process instead of per request.
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: {
// The page mounts <CopilotKit agent="voice-demo">; resolve that to the
// unified Langroid AG-UI endpoint.
"voice-demo": voiceDemoAgent,
// useAgent() with no args defaults to "default"; alias so any internal
// default-agent lookups resolve against the same agent.
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
// Next.js App Router bindings. Catchall slug forwards every sub-path
// (`/info`, `/agent/:id/run`, `/transcribe`, ...) to the V2 handler.
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,184 @@
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";
// 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";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
function createAgent(path = "/") {
return new HttpAgent({ url: `${AGENT_URL}${path}` });
}
// Register the same agent under all names used by demo pages.
// The Langroid agent_server.py exposes a single unified agent on "/" that
// handles every request — so every entry here maps to the same HttpAgent.
const agentNames = [
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-read",
"shared-state-write",
"shared-state-read-write",
"shared-state-streaming",
"subagents",
// Chat chrome variants — all share the unified agent. The frontend
// differentiates via UI composition only (CopilotChat vs Sidebar vs Popup,
// slots, headless useAgent).
"chat-customization-css",
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"headless-simple",
// Frontend-tools variants — backend has no specialized tools; the frontend
// registers handlers via useFrontendTool and the agent calls them.
"frontend_tools",
"frontend-tools-async",
// HITL variants — use existing agent's schedule_meeting flow.
"hitl-in-chat",
"hitl-in-app",
// Read-only agent context — frontend exposes useAgentContext; same agent.
"readonly-state-agent-context",
// Tool rendering variants — all share the unified agent; frontend differs.
"tool-rendering-default-catchall",
"tool-rendering-custom-catchall",
"tool-rendering-reasoning-chain",
// Declarative A2UI + fixed-schema A2UI — use the agent's generate_a2ui tool.
"declarative-gen-ui",
"a2ui-fixed-schema",
// Agent-config, open-gen-ui, headless-complete all reuse the unified agent.
"agent-config",
"open-gen-ui",
"open-gen-ui-advanced",
"headless-complete",
// Interrupt demos (Strategy B — frontend-tool async handler)
"gen-ui-interrupt",
"interrupt-headless",
];
// Reasoning agent names — backed by the reasoning-enabled sub-app at
// /reasoning. Langroid's stock unified agent calls OpenAI non-streaming and
// drops the model's reasoning_content channel, so reasoning cells route here
// instead. 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`). `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",
];
const agents: Record<string, AbstractAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
for (const name of reasoningAgentNames) {
agents[name] = createAgent("/reasoning/");
}
agents["default"] = createAgent();
// gen-ui-agent owns a typed `steps` slice of shared state that the
// unified `/` agent does not implement (it has no `set_steps` tool).
// Route this agent name at a dedicated backend endpoint that drives
// the pending -> in_progress -> completed state machine and emits
// STATE_SNAPSHOT events between transitions.
agents["gen-ui-agent"] = new HttpAgent({ url: `${AGENT_URL}/gen-ui-agent` });
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: "langroid",
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: "langroid",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "langroid";
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] */
@@ -0,0 +1,112 @@
"use client";
import React from "react";
import {
CopilotKit,
CopilotChat,
CopilotChatAssistantMessage,
CopilotChatUserMessage,
CopilotChatReasoningMessage,
CopilotChatView,
CopilotChatInput,
} from "@copilotkit/react-core/v2";
import {
CustomWelcomeScreen,
CustomAssistantMessage,
CustomUserMessage,
CustomReasoningMessage,
CustomCursor,
CustomTextArea,
CustomSendButton,
CustomDisclaimer,
CustomAddMenuButton,
CustomSuggestionContainer,
CustomSuggestion,
CustomScrollToBottomButton,
CustomFeather,
} from "./slot-wrappers";
import { makeSlotOverride } from "../_shared/slot-override";
import { useChatSlotsSuggestions } from "./suggestions";
// "Slot Atlas" — every overrideable slot on CopilotChat is wrapped in a
// dashed, color-coded marker so a developer can see at a glance what is
// customizable and where it lives. Hover any region to reveal its slot path.
export default function ChatSlotsDemo() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="chat-slots">
<div className="flex flex-col h-screen w-full bg-background">
<div className="flex-1 flex justify-center items-stretch p-4 min-h-0">
<div className="h-full w-full max-w-5xl">
<Chat />
</div>
</div>
</div>
</CopilotKit>
);
}
function Chat() {
useChatSlotsSuggestions();
// Slot overrides go through `makeSlotOverride<TDefault>(component)` so
// the cast is centralized in one named helper instead of sprinkled
// through this file. See `../_shared/slot-override.ts` for the why.
const welcomeScreen =
makeSlotOverride<typeof CopilotChatView.WelcomeScreen>(CustomWelcomeScreen);
// The input prop accepts both slot overrides AND CopilotChatInput's rest
// props (toolsMenu, mode, etc.) merged together. We seed `toolsMenu` so the
// addMenuButton slot has a reason to render.
const input = {
textArea:
makeSlotOverride<typeof CopilotChatInput.TextArea>(CustomTextArea),
sendButton:
makeSlotOverride<typeof CopilotChatInput.SendButton>(CustomSendButton),
disclaimer:
makeSlotOverride<typeof CopilotChatInput.Disclaimer>(CustomDisclaimer),
addMenuButton:
makeSlotOverride<typeof CopilotChatInput.AddMenuButton>(
CustomAddMenuButton,
),
toolsMenu: [
{
label: "Demo tool (no-op)",
action: () => {},
},
],
};
const messageView = {
assistantMessage: makeSlotOverride<typeof CopilotChatAssistantMessage>(
CustomAssistantMessage,
),
userMessage:
makeSlotOverride<typeof CopilotChatUserMessage>(CustomUserMessage),
reasoningMessage: makeSlotOverride<typeof CopilotChatReasoningMessage>(
CustomReasoningMessage,
),
cursor: CustomCursor,
};
const suggestionView = {
container: CustomSuggestionContainer,
suggestion: CustomSuggestion,
};
const scrollView = {
scrollToBottomButton: CustomScrollToBottomButton,
feather: CustomFeather,
};
return (
<CopilotChat
agentId="chat-slots"
className="h-full rounded-2xl border border-border/60 bg-card overflow-hidden"
welcomeScreen={welcomeScreen}
input={input}
messageView={messageView}
suggestionView={suggestionView}
scrollView={scrollView}
/>
);
}
@@ -0,0 +1,161 @@
"use client";
import React, { useCallback, useState } from "react";
export type SlotColor =
| "indigo"
| "violet"
| "emerald"
| "sky"
| "amber"
| "rose"
| "orange"
| "red"
| "yellow"
| "pink"
| "cyan"
| "teal"
| "lime"
| "fuchsia";
// Static lookups so Tailwind v4's source scanner finds every class string at
// build time. Dynamic concatenation like `border-${color}-400` would not work.
export const SLOT_COLORS: Record<
SlotColor,
{ border: string; label: string; ring: string }
> = {
indigo: {
border: "border-indigo-400",
label: "bg-indigo-500",
ring: "ring-indigo-400/40",
},
violet: {
border: "border-violet-400",
label: "bg-violet-500",
ring: "ring-violet-400/40",
},
emerald: {
border: "border-emerald-400",
label: "bg-emerald-500",
ring: "ring-emerald-400/40",
},
sky: {
border: "border-sky-400",
label: "bg-sky-500",
ring: "ring-sky-400/40",
},
amber: {
border: "border-amber-400",
label: "bg-amber-500",
ring: "ring-amber-400/40",
},
rose: {
border: "border-rose-400",
label: "bg-rose-500",
ring: "ring-rose-400/40",
},
orange: {
border: "border-orange-400",
label: "bg-orange-500",
ring: "ring-orange-400/40",
},
red: {
border: "border-red-400",
label: "bg-red-500",
ring: "ring-red-400/40",
},
yellow: {
border: "border-yellow-400",
label: "bg-yellow-500",
ring: "ring-yellow-400/40",
},
pink: {
border: "border-pink-400",
label: "bg-pink-500",
ring: "ring-pink-400/40",
},
cyan: {
border: "border-cyan-400",
label: "bg-cyan-500",
ring: "ring-cyan-400/40",
},
teal: {
border: "border-teal-400",
label: "bg-teal-500",
ring: "ring-teal-400/40",
},
lime: {
border: "border-lime-400",
label: "bg-lime-500",
ring: "ring-lime-400/40",
},
fuchsia: {
border: "border-fuchsia-400",
label: "bg-fuchsia-500",
ring: "ring-fuchsia-400/40",
},
};
/**
* Wraps a slot region with a dashed outline plus a small clickable badge
* that copies the slot's component path to the clipboard.
*
* The label is opacity-0 by default and turns visible only when this marker
* is hovered AND no descendant marker is also hovered. Markers nest
* (welcomeScreen wraps welcomeMessage / input / suggestionView), so a plain
* `:hover .slot-label { opacity: 1 }` would light up every nested label.
* The `:not(:has(.slot-marker:hover))` predicate isolates each marker.
*/
export function SlotMarker({
color,
label,
children,
inline,
className,
}: {
color: SlotColor;
label: string;
children: React.ReactNode;
inline?: boolean;
className?: string;
}) {
const c = SLOT_COLORS[color];
const [copied, setCopied] = useState(false);
const onCopy = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(label);
setCopied(true);
setTimeout(() => setCopied(false), 1100);
} catch {
// clipboard may be unavailable (e.g. insecure context); silently no-op
}
},
[label],
);
return (
<span
data-slot-label={label}
className={`slot-marker relative ${inline ? "inline-flex" : "flex"} border border-dashed ${c.border} rounded-lg p-1 ${className ?? ""}`}
style={{ flexDirection: inline ? "row" : "column" }}
>
<button
type="button"
onClick={onCopy}
title={copied ? "Copied!" : `Copy slot path: ${label}`}
aria-label={`Copy slot path ${label}`}
className={`slot-label absolute -top-2 left-2 inline-flex items-center gap-1 rounded ${c.label} text-white text-[9px] font-bold uppercase tracking-wider px-1.5 py-px shadow-sm z-10 whitespace-nowrap opacity-0 transition-opacity hover:brightness-110 cursor-pointer pointer-events-auto font-mono normal-case tracking-normal`}
>
<span>{copied ? "Copied" : label}</span>
<span aria-hidden="true" className="text-white/70 text-[8px]">
{copied ? "✓" : "⧉"}
</span>
</button>
<span style={{ display: "contents" }}>{children}</span>
</span>
);
}
@@ -0,0 +1,45 @@
// Docs-only snippet — not imported or rendered. The langgraph-python
// chat-slots production demo registers a dozen slot overrides at once
// (see page.tsx) with `as unknown as typeof X` casts that exist to
// satisfy the WithSlots types when the wrappers are structurally
// compatible but not nominally identical. That's necessary in the
// running app but obscures the teaching shape.
//
// This file gives the slots docs page (custom-look-and-feel/slots.mdx)
// three minimal teaching examples — the welcome screen, assistant
// message, and disclaimer slot patterns — without changing the
// production demo's runtime behavior. See agentic-chat /
// chat-component.snippet.tsx for the same sibling-file pattern.
// @region[register-disclaimer-slot]
// @region[register-assistant-message-slot]
// @region[register-welcome-slot]
import type {
CopilotChatAssistantMessage,
CopilotChatInput,
CopilotChatView,
} from "@copilotkit/react-core/v2";
declare const CustomWelcomeScreen: React.ComponentType;
declare const CustomAssistantMessage: React.ComponentType;
declare const CustomDisclaimer: React.ComponentType;
export function ChatSlotsTeachingExtracts() {
const welcomeScreen =
CustomWelcomeScreen as unknown as typeof CopilotChatView.WelcomeScreen;
// @endregion[register-welcome-slot]
const messageView = {
assistantMessage:
CustomAssistantMessage as unknown as typeof CopilotChatAssistantMessage,
};
// @endregion[register-assistant-message-slot]
const input = {
disclaimer:
CustomDisclaimer as unknown as typeof CopilotChatInput.Disclaimer,
};
// @endregion[register-disclaimer-slot]
return { welcomeScreen, messageView, input };
}
@@ -0,0 +1,287 @@
"use client";
import React from "react";
import {
CopilotChatAssistantMessage,
CopilotChatUserMessage,
CopilotChatReasoningMessage,
CopilotChatMessageView,
CopilotChatView,
CopilotChatInput,
CopilotChatSuggestionPill,
type CopilotChatAssistantMessageProps,
type CopilotChatUserMessageProps,
type CopilotChatReasoningMessageProps,
type CopilotChatSuggestionPillProps,
} from "@copilotkit/react-core/v2";
import { SlotMarker } from "./slot-marker";
// =====================================================================
// welcomeScreen + welcomeScreen.welcomeMessage
// The welcomeScreen receives `input` and `suggestionView` as elements; we
// also expose the `welcomeMessage` sub-slot to show that slots can nest.
// =====================================================================
export function CustomWelcomeMessage(
props: React.HTMLAttributes<HTMLDivElement>,
) {
return (
<SlotMarker color="violet" label="WelcomeScreen.WelcomeMessage">
<div
{...props}
className="text-center px-4 py-3 text-sm text-muted-foreground"
data-testid="custom-welcome-message"
>
Hover any region to see its slot path · click the badge to copy
</div>
</SlotMarker>
);
}
export function CustomWelcomeScreen({
input,
suggestionView,
}: {
input: React.ReactElement;
suggestionView: React.ReactElement;
welcomeMessage?: React.ReactElement;
}) {
return (
<SlotMarker color="indigo" label="WelcomeScreen" className="flex-1 m-3">
<div
data-testid="custom-welcome-screen"
className="flex-1 flex flex-col items-center justify-center px-4 py-6 gap-4 w-full"
>
<CustomWelcomeMessage />
<div className="w-full max-w-2xl">{input}</div>
<div className="flex justify-center">{suggestionView}</div>
</div>
</SlotMarker>
);
}
// =====================================================================
// messageView.assistantMessage
// =====================================================================
export function CustomAssistantMessage(
props: CopilotChatAssistantMessageProps,
) {
return (
<SlotMarker
color="emerald"
label="MessageView.AssistantMessage"
className="my-3"
>
<CopilotChatAssistantMessage {...props} />
</SlotMarker>
);
}
// =====================================================================
// messageView.userMessage
// =====================================================================
export function CustomUserMessage(props: CopilotChatUserMessageProps) {
return (
<SlotMarker
color="sky"
label="MessageView.UserMessage"
className="my-3 ml-auto"
>
<CopilotChatUserMessage {...props} />
</SlotMarker>
);
}
// =====================================================================
// messageView.reasoningMessage
// Only renders when the message stream contains reasoning content.
// =====================================================================
export function CustomReasoningMessage(
props: CopilotChatReasoningMessageProps,
) {
return (
<SlotMarker
color="rose"
label="MessageView.ReasoningMessage"
className="my-2"
>
<CopilotChatReasoningMessage {...props} />
</SlotMarker>
);
}
// =====================================================================
// messageView.cursor
// Renders while a message is streaming. Tiny — wrap inline.
// =====================================================================
export function CustomCursor(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<SlotMarker color="amber" label="MessageView.Cursor" inline>
<CopilotChatMessageView.Cursor {...props} />
</SlotMarker>
);
}
// =====================================================================
// input.textArea
// We wrap the default in a SlotMarker. The marker is `display: contents`-ish
// inside; the dashed border is on the marker's outer span.
// =====================================================================
export function CustomTextArea(
props: React.ComponentProps<typeof CopilotChatInput.TextArea>,
) {
return (
<SlotMarker
color="orange"
label="Input.TextArea"
className="flex-1 min-w-0"
>
<CopilotChatInput.TextArea {...props} />
</SlotMarker>
);
}
// =====================================================================
// input.sendButton
// =====================================================================
export function CustomSendButton(
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
return (
<SlotMarker color="red" label="Input.SendButton" inline>
<CopilotChatInput.SendButton {...props} />
</SlotMarker>
);
}
// =====================================================================
// input.disclaimer
// =====================================================================
export function CustomDisclaimer(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<SlotMarker
color="yellow"
label="Input.Disclaimer"
className="mx-auto my-1.5"
>
<div
{...props}
data-testid="custom-disclaimer"
className="text-xs text-center text-muted-foreground px-2 py-1"
>
Custom disclaimer slot · stays visible in every input variant
</div>
</SlotMarker>
);
}
// =====================================================================
// input.addMenuButton
// Only renders if `onAddFile` or `toolsMenu` is set on CopilotChatInput.
// =====================================================================
export function CustomAddMenuButton(
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
return (
<SlotMarker color="pink" label="Input.AddMenuButton" inline>
<CopilotChatInput.AddMenuButton {...props} />
</SlotMarker>
);
}
// =====================================================================
// suggestionView.container
// =====================================================================
export const CustomSuggestionContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function CustomSuggestionContainer(props, ref) {
return (
<SlotMarker color="cyan" label="SuggestionView.Container" className="my-2">
<div ref={ref} {...props} />
</SlotMarker>
);
});
// =====================================================================
// suggestionView.suggestion
// =====================================================================
export const CustomSuggestion = React.forwardRef<
HTMLButtonElement,
CopilotChatSuggestionPillProps
>(function CustomSuggestion(props, ref) {
return (
<SlotMarker color="teal" label="SuggestionView.Suggestion" inline>
<CopilotChatSuggestionPill ref={ref} {...props} />
</SlotMarker>
);
});
// =====================================================================
// scrollView.scrollToBottomButton
// =====================================================================
export function CustomScrollToBottomButton(
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
return (
<SlotMarker
color="lime"
label="ScrollView.ScrollToBottomButton"
inline
className="absolute bottom-20 right-6"
>
<CopilotChatView.ScrollToBottomButton {...props} />
</SlotMarker>
);
}
// =====================================================================
// scrollView.feather
// The default Feather is the gradient fade above the input. The default
// implementation is an empty div with absolute positioning, so we render
// our own visible gradient + a clickable copy badge so the slot is
// unambiguously visible.
// =====================================================================
export function CustomFeather(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
data-testid="custom-feather"
className="slot-marker pointer-events-none absolute left-0 right-0 bottom-0 h-12 bg-gradient-to-t from-fuchsia-100/90 to-transparent dark:from-fuchsia-950/40"
>
<FeatherCopyLabel />
</div>
);
}
function FeatherCopyLabel() {
const label = "ScrollView.Feather";
const [copied, setCopied] = React.useState(false);
const onCopy = React.useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(label);
setCopied(true);
setTimeout(() => setCopied(false), 1100);
} catch {
// clipboard may be unavailable in non-secure contexts
}
},
[],
);
return (
<button
type="button"
onClick={onCopy}
title={copied ? "Copied!" : `Copy slot path: ${label}`}
aria-label={`Copy slot path ${label}`}
className="slot-label absolute -top-2 left-2 inline-flex items-center gap-1 rounded bg-fuchsia-500 text-white text-[9px] font-bold px-1.5 py-px shadow-sm z-10 whitespace-nowrap opacity-0 transition-opacity hover:brightness-110 cursor-pointer pointer-events-auto font-mono"
>
<span>{copied ? "Copied" : label}</span>
<span aria-hidden="true" className="text-white/70 text-[8px]">
{copied ? "✓" : "⧉"}
</span>
</button>
);
}
@@ -0,0 +1,19 @@
"use client";
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
// The chat-slots cell is wired to the neutral `sample_agent` graph
// (plain ChatOpenAI, no Responses API, no reasoning config), so it never
// emits AG-UI REASONING_MESSAGE_* events — the `messageView.reasoningMessage`
// slot is wrapped for the slot-atlas demo but stays dormant here. A
// "Show reasoning" pill therefore can't light it up; that demo lives at
// /demos/reasoning-default and /demos/reasoning-custom instead.
export function useChatSlotsSuggestions() {
useConfigureSuggestions({
suggestions: [
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
{ title: "Tell me a joke", message: "Tell me a short joke." },
],
available: "always",
});
}
@@ -0,0 +1,37 @@
"use client";
/**
* ShadCN-flavoured Badge primitive (inline-cloned, no `cn()`/`cva`).
* Variant palette mirrors ShadCN's default/secondary/destructive/outline,
* extended with `success` / `warning` / `info` for status reporting.
*/
import React from "react";
export type BadgeVariant = "success" | "warning" | "error" | "info";
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
success: "border-transparent bg-emerald-100 text-emerald-800",
warning: "border-transparent bg-amber-100 text-amber-800",
error: "border-transparent bg-rose-100 text-rose-800",
info: "border-[var(--border)] bg-[var(--muted)] text-[var(--foreground)]",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
export function Badge({
variant = "info",
className = "",
children,
...props
}: BadgeProps) {
return (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium tracking-wide ${VARIANT_CLASSES[variant]} ${className}`}
{...props}
>
{children}
</span>
);
}

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