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,432 @@
"""_cvdiag_backend.py — schema-v1 backend CVDIAG emitter for langgraph-fastapi.
This is the langgraph-fastapi realization of the §3 backend layer: it wires
the 11 backend boundaries through the shared ``_shared.cvdiag_bootstrap.emit_cvdiag``
single-source emitter. It is the middleware-style sibling of the langgraph-python
(LGP) emitter (same ``AgentMiddleware`` wrap path, different ``slug``). It runs
ALONGSIDE the legacy free-form ``_cvdiag()`` log lines in
``_header_forwarding_middleware.py`` (dual-emit during the transition):
- legacy ``_cvdiag()`` keeps writing the human-grep ``CVDIAG component=...`` line,
- this module writes the structured schema-v1 ``CVDIAG {json}`` envelope.
Guard: every emit here is gated on ``CVDIAG_BACKEND_EMITTER=1`` (default OFF). With
the guard off this module is a pure no-op — it never validates, never writes, never
throws into the observed boundary.
The 11 backend boundaries (spec §3 / §5):
backend.request.ingress, backend.agent.enter, backend.llm.call.start,
backend.llm.call.heartbeat (VERBOSE tier — periodic ~10s asyncio task),
backend.llm.call.response, backend.sse.first_byte, backend.sse.event (DEBUG tier),
backend.sse.aborted, backend.agent.exit, backend.response.complete,
backend.error.caught.
Pure instrumentation: like the shared emitter, nothing here raises into the caller;
the one place we ``await`` (the heartbeat task) is cancelled cleanly in a finally.
Plan unit: L1-D3 (mirrors L1-I).
"""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
# ── Tier gating ───────────────────────────────────────────────────────────────
# The shared bootstrap already resolved the tier (default | verbose | debug) and
# applied the §6 fail-closed DEBUG guard. We mirror the §6 tier matrix locally so
# VERBOSE-only (heartbeat) and DEBUG-only (sse.event) boundaries are suppressed at
# the wrong tier rather than relying on the consumer to filter.
_VERBOSE_TIERS = frozenset({"verbose", "debug"})
_DEBUG_TIERS = frozenset({"debug"})
_SLUG = "langgraph-fastapi"
_HEARTBEAT_INTERVAL_S = 10.0
def emitter_enabled() -> bool:
"""True when the schema-v1 backend emitter is armed (``CVDIAG_BACKEND_EMITTER=1``).
Default OFF: a missing/any-other value disables every emit in this module.
"""
return os.environ.get("CVDIAG_BACKEND_EMITTER") == "1"
def _active_tier() -> str:
"""Resolve the verbosity tier from a LIVE env read.
``emitter_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 (heartbeat, sse.event). 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 _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _mono_ns() -> int:
return time.monotonic_ns()
def _span_id() -> str:
"""16 hex chars (8 random bytes) — matches SPAN_ID_PATTERN."""
return uuid.uuid4().hex[:16]
def _coerce_test_id(raw: Optional[str]) -> str:
"""Return a schema-valid UUIDv7 ``test_id``.
When the inbound header carries a valid UUIDv7 we keep it (this is the
propagation we are measuring). When it is absent/malformed we synthesize a
deterministic-shape UUIDv7 so the envelope still validates — but the
propagation gate measures the *inbound* presence, not this fallback.
"""
from _shared.cvdiag_schema import (
TEST_ID_PATTERN,
) # local import: cheap, avoids cycle
import re
if isinstance(raw, str) and re.match(TEST_ID_PATTERN, raw):
return raw
# Synthesize a UUIDv7-shaped value (version nibble 7, variant 8..b).
hexs = uuid.uuid4().hex
return f"{hexs[0:8]}-{hexs[8:12]}-7{hexs[13:16]}-8{hexs[17:20]}-{hexs[20:32]}"
def extract_test_id(headers: Dict[str, str]) -> Optional[str]:
"""Return the inbound ``x-test-id`` header value, or ``None`` when absent.
This is the raw inbound value used by the propagation-reliability gate — it
is NOT coerced/synthesized here so the gate can measure true propagation.
"""
raw = headers.get("x-test-id")
return raw if isinstance(raw, str) and raw else None
def _empty_edge_headers() -> Dict[str, Any]:
return {
"cf-ray": None,
"cf-mitigated": None,
"cf-cache-status": None,
"x-railway-edge": None,
"x-railway-request-id": None,
"x-hikari-trace": None,
"retry-after": None,
"via": None,
"server": None,
}
def _edge_headers_from(headers: Dict[str, str]) -> Dict[str, Any]:
"""Project the inbound header bag onto the closed 9-key edge-header shape.
Only the 9 allow-listed keys are carried; everything else is dropped (the
envelope's per-boundary model + EdgeHeaders ``extra=forbid`` enforce this).
"""
allow = _empty_edge_headers()
for key in list(allow.keys()):
val = headers.get(key)
if isinstance(val, str) and val:
allow[key] = val
return allow
def _emit(
boundary: str,
*,
headers: Dict[str, str],
trace_id: str,
outcome: str = "ok",
metadata: Optional[Dict[str, Any]] = None,
duration_ms: Optional[int] = None,
demo: str = "chat",
tier_gate: Optional[frozenset] = None,
) -> None:
"""Build + emit one schema-v1 envelope, guarded + tier-filtered.
No-op when the emitter is disabled OR the boundary's tier gate excludes the
resolved tier. Never raises (delegates to the shared emitter's safety).
"""
if not emitter_enabled():
return
if tier_gate is not None and _active_tier() not in tier_gate:
return
envelope = {
"schema_version": 1,
"test_id": _coerce_test_id(headers.get("x-test-id")),
"trace_id": trace_id,
"span_id": _span_id(),
"parent_span_id": None,
"layer": "backend",
"boundary": boundary,
"slug": _SLUG,
"demo": demo,
"ts": _now_iso(),
"mono_ns": _mono_ns(),
"duration_ms": duration_ms,
"outcome": outcome,
"edge_headers": _edge_headers_from(headers),
"metadata": metadata or {},
}
emit_cvdiag(envelope)
class CvdiagBackendRun:
"""Per-model-call CVDIAG run context for the LGP middleware.
Constructed inside ``awrap_model_call`` (and the sync ``wrap_model_call``);
owns the trace correlation id, the ingress monotonic anchor, and the
heartbeat asyncio task. All methods are no-ops when the emitter is disabled.
"""
def __init__(self, headers: Dict[str, str]) -> None:
self._headers = dict(headers)
# Correlate every boundary in this run under one trace_id. Prefer the
# inbound x-diag-run-id breadcrumb so probe/backend rows join; fall back
# to a synthesized id.
self._trace_id = (
headers.get("x-diag-run-id") or headers.get("x-test-id") or uuid.uuid4().hex
)
self._ingress_mono = _mono_ns()
self._first_byte_emitted = False
self._sse_seq = 0
self._heartbeat_task: Optional[asyncio.Task] = None
# ── Lifecycle boundaries ──────────────────────────────────────────────
def request_ingress(self) -> None:
_emit(
"backend.request.ingress",
headers=self._headers,
trace_id=self._trace_id,
metadata={"method": "POST", "path": "/threads", "content_length": None},
tier_gate=_VERBOSE_TIERS,
)
def agent_enter(
self, agent_name: Optional[str] = None, model_id: Optional[str] = None
) -> None:
_emit(
"backend.agent.enter",
headers=self._headers,
trace_id=self._trace_id,
metadata={"agent_name": agent_name, "model_id": model_id},
)
def llm_call_start(
self, provider: Optional[str] = None, model: Optional[str] = None
) -> None:
_emit(
"backend.llm.call.start",
headers=self._headers,
trace_id=self._trace_id,
metadata={
"provider": provider,
"model": model,
"prompt_token_count_estimate": None,
},
tier_gate=_VERBOSE_TIERS,
)
def llm_call_response(
self,
provider: Optional[str] = None,
model: Optional[str] = None,
latency_ms: Optional[int] = None,
error_class: Optional[str] = None,
) -> None:
_emit(
"backend.llm.call.response",
headers=self._headers,
trace_id=self._trace_id,
outcome="err" if error_class else "ok",
metadata={
"provider": provider,
"model": model,
"response_token_count": None,
"latency_ms": latency_ms,
"error_class": error_class,
},
tier_gate=_VERBOSE_TIERS,
)
def sse_first_byte(self) -> None:
"""Emit ``backend.sse.first_byte`` once, with the ingress→first-byte delta."""
if self._first_byte_emitted:
return
self._first_byte_emitted = True
delta_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
_emit(
"backend.sse.first_byte",
headers=self._headers,
trace_id=self._trace_id,
metadata={"delta_ms_from_ingress": delta_ms},
tier_gate=_VERBOSE_TIERS,
)
def sse_event(
self, event_type: Optional[str] = None, payload_size_bytes: Optional[int] = None
) -> None:
"""Emit ``backend.sse.event`` (DEBUG tier — suppressed below debug)."""
self._sse_seq += 1
_emit(
"backend.sse.event",
headers=self._headers,
trace_id=self._trace_id,
metadata={
"event_type": event_type,
"payload_size_bytes": payload_size_bytes,
"sequence_num": self._sse_seq,
},
tier_gate=_DEBUG_TIERS,
)
def sse_aborted(
self,
termination_kind: Optional[str] = None,
bytes_before_abort: Optional[int] = None,
) -> None:
_emit(
"backend.sse.aborted",
headers=self._headers,
trace_id=self._trace_id,
outcome="err",
metadata={
"termination_kind": termination_kind,
"bytes_before_abort": bytes_before_abort,
},
)
def agent_exit(self, terminal_outcome: str = "ok") -> None:
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
_emit(
"backend.agent.exit",
headers=self._headers,
trace_id=self._trace_id,
outcome="err" if terminal_outcome == "err" else "ok",
metadata={
"terminal_outcome": terminal_outcome,
"total_duration_ms": total_ms,
},
)
def response_complete(
self,
http_status: Optional[int] = 200,
sse_event_count: Optional[int] = None,
) -> None:
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
_emit(
"backend.response.complete",
headers=self._headers,
trace_id=self._trace_id,
metadata={
"http_status": http_status,
"content_length": None,
"total_duration_ms": total_ms,
"sse_event_count": sse_event_count
if sse_event_count is not None
else self._sse_seq,
},
)
def error_caught(self, exc: BaseException) -> None:
_emit(
"backend.error.caught",
headers=self._headers,
trace_id=self._trace_id,
outcome="err",
metadata={
"exception_type": type(exc).__name__,
"message_scrubbed": "<scrubbed>",
"stack_brief": None,
"truncated": False,
},
)
# ── Heartbeat (VERBOSE tier — periodic asyncio task) ──────────────────
async def _heartbeat_loop(self) -> None:
"""Emit ``backend.llm.call.heartbeat`` every ~10s while the LLM call runs."""
try:
while True:
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
_emit(
"backend.llm.call.heartbeat",
headers=self._headers,
trace_id=self._trace_id,
outcome="info",
metadata={"elapsed_ms_since_start": elapsed_ms},
tier_gate=_VERBOSE_TIERS,
)
except asyncio.CancelledError:
# Clean cancellation when the LLM call finishes — swallow.
return
def start_heartbeat(self) -> None:
"""Arm the heartbeat task (no-op when disabled or below VERBOSE tier)."""
if not emitter_enabled() or _active_tier() not in _VERBOSE_TIERS:
return
if self._heartbeat_task is not None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
self._heartbeat_task = loop.create_task(self._heartbeat_loop())
async def stop_heartbeat(self) -> None:
"""Cancel + await the heartbeat task. Safe to call when never started.
Cooperative cancellation: the legacy ``except (CancelledError,
Exception)`` swallowed the CALLER's CancelledError, breaking cooperative
cancellation (a client-disconnect / request-cancel that arrives while we
await the heartbeat task would be lost). We suppress ONLY the heartbeat
task's OWN cancellation — the one we just requested — and re-raise when
THIS task is being cancelled by the caller (a pending cancellation
request, ``current_task().cancelling() > 0``). ``Task.cancelling()`` is
3.11+ (production runs 3.12); on older runtimes the attribute is absent
and we degrade to suppressing (the legacy behavior).
"""
task = self._heartbeat_task
if task is None:
return
self._heartbeat_task = None
task.cancel()
try:
await task
except asyncio.CancelledError:
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
return
def emit_heartbeat_once(self) -> None:
"""Synchronous single heartbeat emit (used by the sync wrap path + tests)."""
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
_emit(
"backend.llm.call.heartbeat",
headers=self._headers,
trace_id=self._trace_id,
outcome="info",
metadata={"elapsed_ms_since_start": elapsed_ms},
tier_gate=_VERBOSE_TIERS,
)
@@ -0,0 +1,282 @@
"""Minimal header-forwarding-only AgentMiddleware.
Some showcase demos (reasoning, tool-rendering-reasoning-chain, the
sub-agents in `subagents.py`) intentionally avoid the full
`CopilotKitMiddleware` because they don't need its frontend-tool
injection, App-Context surfacing, or state-note features — they're
minimal demos of LangGraph capabilities.
But every showcase request goes through aimock (the locally-served
LLM mock), and aimock requires the ``x-aimock-context`` header (and
friends) on every ``/v1/responses`` and ``/v1/chat/completions``
request to match the right fixture. Without middleware to populate
the header-forwarding ContextVar from the LangGraph RunnableConfig
``configurable``, those requests go out without the header and aimock
returns 404, breaking the demo.
This middleware does ONLY that header propagation — nothing else.
It reuses copilotkit's own primitives (kept private but exported by
the installed package at the module level) so the propagation logic
is identical to the full middleware. No App-Context injection, no
tool-merging, no state-to-prompt surfacing, no Bedrock message
fix-up.
CVDIAG instrumentation (diagnostic only — DOES NOT change WHERE
headers come from): after the existing
``_extract_forwarded_headers_from_config()`` populates copilotkit's
forwarded-headers ContextVar, we read it back via
``get_forwarded_headers()`` and emit a structured ``CVDIAG`` log line
at the configurable-read boundary recording whether
``x-aimock-context`` actually arrived on the LangGraph configurable
channel (``header_present=false`` is the alarm we are hunting). We
also append this layer's hop tag to ``x-diag-hops`` on the SAME
ContextVar the httpx hook already forwards from — so the breadcrumb
and correlation headers (``x-diag-run-id``, ``x-diag-hops``) ride
along on the outbound LLM call exactly the way ``x-aimock-context``
does, without introducing any new forwarding source.
"""
from __future__ import annotations
# CVDIAG bootstrap — folded-in L1-H bootstrap. langgraph-fastapi has no
# entrypoint of its own (the langgraph dev/server loads graphs from
# langgraph.json), so this header-forwarding middleware module — imported by
# every graph's middleware list — is the earliest shared import point. Importing
# the bootstrap here configures the root logger so the legacy ``_cvdiag()`` lines
# actually EMIT and resolves the verbosity tier + PB writer. It imports
# pydantic/starlette only (NOT langchain/langgraph), so it is import-safe.
import _shared.cvdiag_bootstrap # noqa: F401 (bootstrap side effects)
import logging
import time
from typing import Any, Awaitable, Callable, Dict
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ModelRequest,
ModelResponse,
)
# Reuse the installed copilotkit's existing header-forwarding helpers so
# the behaviour stays bit-identical to the full CopilotKitMiddleware's
# header-propagation step. These are module-level functions in
# copilotkit 0.1.94's copilotkit_lg_middleware module.
from copilotkit.copilotkit_lg_middleware import (
_extract_forwarded_headers_from_config,
_ensure_httpx_hook,
)
# CVDIAG-only: read/append the forwarded-header ContextVar copilotkit
# already populates. set_forwarded_headers is used SOLELY to append the
# diagnostic hop breadcrumb onto the SAME channel x-aimock-context rides;
# it does not introduce a new forwarding source.
from copilotkit.header_propagation import (
get_forwarded_headers,
set_forwarded_headers,
)
# CVDIAG schema-v1 backend emitter (L1-D3, mirrors LGP's L1-I). Dual-emit: this
# rides ALONGSIDE the legacy free-form _cvdiag() log lines below — it writes the
# structured schema-v1 CVDIAG envelope through the shared single-source emitter,
# guarded by CVDIAG_BACKEND_EMITTER (default OFF). With the guard off it is a
# pure no-op.
from src.agents.src._cvdiag_backend import CvdiagBackendRun
logger = logging.getLogger(__name__)
_CVDIAG_COMPONENT = "backend-langgraph-fastapi"
_CVDIAG_HOP_TAG = "backend-langgraph-fastapi"
def _cvdiag(
boundary: str,
headers: Dict[str, str],
status: str,
*,
hop: Any = "-",
error: str = "",
) -> None:
"""Emit a single CVDIAG log line in the shared cross-language convention.
Never logs full header values — only a 12-char prefix of
``x-aimock-context``.
"""
slug = headers.get("x-aimock-context")
header_present = isinstance(slug, str) and len(slug) > 0
run_id = headers.get("x-diag-run-id", "none")
test_id = headers.get("x-test-id", "none")
prefix = slug[:12] if header_present else ""
logger.info(
"CVDIAG component=%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_COMPONENT,
boundary,
run_id,
slug if header_present else "MISSING",
str(header_present).lower(),
prefix,
hop,
status,
test_id,
error,
)
def _instrument_and_breadcrumb() -> None:
"""Read the configurable-read result, log it, and append the diag hop.
Called immediately AFTER
``_extract_forwarded_headers_from_config()`` has populated the
ContextVar. Reads the headers back, emits the configurable-read
CVDIAG line (wrapping the previously-silent "no x-aimock-context in
configurable" case as an alarm), then — only when x-aimock-context
is present — appends this layer's hop tag to ``x-diag-hops`` on the
SAME ContextVar so the breadcrumb rides the existing forwarding path.
"""
headers = dict(get_forwarded_headers())
has_context = (
isinstance(headers.get("x-aimock-context"), str)
and len(headers.get("x-aimock-context", "")) > 0
)
if has_context:
_cvdiag("configurable-read", headers, "ok")
else:
# The alarm we are hunting: the configurable channel reached this
# middleware without x-aimock-context. Surface it instead of the
# previous silent no-op.
_cvdiag(
"configurable-read",
headers,
"miss" if headers else "error",
error="x-aimock-context-absent-in-configurable"
if headers
else "no-forwarded-headers-in-configurable",
)
# Nothing to breadcrumb onto — do not invent a forwarding source.
return
# Append this layer's hop tag to x-diag-hops on the SAME ContextVar the
# httpx hook forwards from. This rides the existing path; no new source.
existing_hops = headers.get("x-diag-hops", "")
headers["x-diag-hops"] = (
f"{existing_hops},{_CVDIAG_HOP_TAG}"
if isinstance(existing_hops, str) and existing_hops
else _CVDIAG_HOP_TAG
)
set_forwarded_headers(headers)
hop = len([h for h in headers["x-diag-hops"].split(",") if h])
_cvdiag("outbound-llm", headers, "ok", hop=hop)
class HeaderForwardingMiddleware(AgentMiddleware[AgentState, Any]):
"""AgentMiddleware that only forwards inbound x-* headers.
Behaviourally a no-op except for two calls inside both
``wrap_model_call`` and ``awrap_model_call``:
1. ``_extract_forwarded_headers_from_config()`` — read the
``x-*`` keys from the active LangGraph RunnableConfig
(``context`` and ``configurable``) and populate the
header-forwarding ContextVar.
2. ``_ensure_httpx_hook(request.model)`` — install copilotkit's
httpx event hook on the model's underlying HTTP client(s)
so the next outgoing LLM request picks the headers up.
No App-Context injection, no tool-merging, no state-surfacing,
no Bedrock message fix-up — strictly header propagation.
CVDIAG: ``_instrument_and_breadcrumb()`` is inserted between the
two steps purely to OBSERVE the configurable-read boundary and tag
the existing breadcrumb. It does not change where headers come from.
"""
@property
def name(self) -> str:
return "HeaderForwardingMiddleware"
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
_extract_forwarded_headers_from_config()
_instrument_and_breadcrumb()
_ensure_httpx_hook(request.model)
# CVDIAG schema-v1 dual-emit (L1-D3). No-op when CVDIAG_BACKEND_EMITTER off.
headers = dict(get_forwarded_headers())
run = CvdiagBackendRun(headers)
model_name = _model_name(request)
run.request_ingress()
run.agent_enter(agent_name=self.name, model_id=model_name)
run.llm_call_start(provider="langchain", model=model_name)
run.emit_heartbeat_once()
start_ns = time.monotonic_ns()
try:
response = handler(request)
except BaseException as exc: # noqa: BLE001 - re-raised after observing
run.error_caught(exc)
run.agent_exit(terminal_outcome="err")
raise
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
run.llm_call_response(
provider="langchain", model=model_name, latency_ms=latency_ms
)
run.sse_first_byte()
run.sse_event(event_type="response", payload_size_bytes=None)
run.agent_exit(terminal_outcome="ok")
run.response_complete(http_status=200)
return response
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
_extract_forwarded_headers_from_config()
_instrument_and_breadcrumb()
_ensure_httpx_hook(request.model)
# CVDIAG schema-v1 dual-emit (L1-D3). No-op when CVDIAG_BACKEND_EMITTER off.
headers = dict(get_forwarded_headers())
run = CvdiagBackendRun(headers)
model_name = _model_name(request)
run.request_ingress()
run.agent_enter(agent_name=self.name, model_id=model_name)
run.llm_call_start(provider="langchain", model=model_name)
run.start_heartbeat()
start_ns = time.monotonic_ns()
try:
response = await handler(request)
except BaseException as exc: # noqa: BLE001 - re-raised after observing
await run.stop_heartbeat()
run.error_caught(exc)
run.agent_exit(terminal_outcome="err")
raise
await run.stop_heartbeat()
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
run.llm_call_response(
provider="langchain", model=model_name, latency_ms=latency_ms
)
run.sse_first_byte()
run.sse_event(event_type="response", payload_size_bytes=None)
run.agent_exit(terminal_outcome="ok")
run.response_complete(http_status=200)
return response
def _model_name(request: ModelRequest) -> str:
"""Best-effort model identifier off the ModelRequest (never raises)."""
try:
model = getattr(request, "model", None)
for attr in ("model_name", "model", "model_id"):
val = getattr(model, attr, None)
if isinstance(val, str) and val:
return val
except Exception: # noqa: BLE001 - instrumentation must not throw
pass
return "unknown"
@@ -0,0 +1,26 @@
"""LangGraph agent for the Declarative Generative UI (A2UI — Dynamic Schema) demo."""
from __future__ import annotations
from copilotkit import CopilotKitMiddleware
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
SYSTEM_PROMPT = (
"You are a demo assistant for Declarative Generative UI (A2UI — Dynamic "
"Schema). Whenever a response would benefit from a rich visual — a "
"dashboard, status report, KPI summary, card layout, info grid, a "
"pie/donut chart of part-of-whole breakdowns, or a bar chart comparing "
"values across categories — call `generate_a2ui` to draw it. The tool "
"renders the surface automatically from the registered component catalog; "
"keep chat replies to one short sentence and let the UI do the talking."
)
graph = create_agent(
model=ChatOpenAI(model="gpt-4.1"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,97 @@
"""
LangGraph 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 loaded at startup via `a2ui.load_schema(...)`. The agent only
streams *data* into the data model at runtime. The frontend registers a
matching catalog (see `src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts`)
that pins the schema's component names to real React implementations.
Reference:
examples/integrations/langgraph-python/agent/src/a2ui_fixed_schema.py
"""
# @region[backend-render-operations]
# @region[backend-schema-json-load]
from __future__ import annotations
from pathlib import Path
from typing import TypedDict
from copilotkit import CopilotKitMiddleware, a2ui
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_openai import ChatOpenAI
CATALOG_ID = "copilotkit://flight-fixed-catalog"
SURFACE_ID = "flight-fixed-schema"
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
# Schemas are JSON so they can be authored and reviewed independently of the
# Python code. `a2ui.load_schema` is just a thin `json.load` wrapper.
FLIGHT_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "flight_schema.json")
BOOKED_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "booked_schema.json")
# @endregion[backend-schema-json-load]
class Flight(TypedDict):
"""Shape the LLM should fill in when calling `display_flight`.
LangGraph serializes this TypedDict into the tool's JSON schema, so
defining it narrowly is how we steer the LLM to produce data that fits
the frontend `FlightCard` component's props.
"""
origin: str
destination: str
airline: str
price: str
@tool
def display_flight(origin: str, destination: str, airline: str, price: str) -> str:
"""Show a flight card for the given trip.
Use short airport codes (e.g. "SFO", "JFK") for origin/destination and a
price string like "$289".
"""
# The A2UI middleware detects the `a2ui_operations` container in this
# tool result and forwards the ops to the frontend renderer. The frontend
# catalog resolves component names to the local React components.
return a2ui.render(
operations=[
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
a2ui.update_data_model(
SURFACE_ID,
{
"origin": origin,
"destination": destination,
"airline": airline,
"price": price,
},
),
],
# NOTE: The canonical reference (and the docs at
# docs/integrations/langgraph/generative-ui/a2ui/fixed-schema.mdx)
# also pass `action_handlers={...}` here to declare optimistic UI
# transitions — e.g. swapping to BOOKED_SCHEMA when the card's
# `book_flight` button is clicked. The Python SDK's `a2ui.render`
# does not yet accept that kwarg (see sdk-python/copilotkit/a2ui.py),
# so we omit it for now. The `booked_schema.json` sibling is kept
# so the schema is ready to wire up once the SDK exposes handlers.
)
# @endregion[backend-render-operations]
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[display_flight],
middleware=[CopilotKitMiddleware()],
system_prompt=(
"You help users find flights. When asked about a flight, call "
"display_flight with origin, destination, airline, and price. "
"Keep any chat reply to one short sentence."
),
)
@@ -0,0 +1,20 @@
[
{
"id": "root",
"component": "Column",
"gap": 8,
"children": ["title", "detail"]
},
{
"id": "title",
"component": "Text",
"text": { "path": "/title" },
"variant": "h2"
},
{
"id": "detail",
"component": "Text",
"text": { "path": "/detail" },
"variant": "body"
}
]
@@ -0,0 +1,77 @@
[
{
"id": "root",
"component": "Card",
"child": "content"
},
{
"id": "content",
"component": "Column",
"children": ["title", "route", "meta", "bookButton"]
},
{
"id": "title",
"component": "Title",
"text": "Flight Details"
},
{
"id": "route",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["from", "arrow", "to"]
},
{
"id": "from",
"component": "Airport",
"code": { "path": "/origin" }
},
{
"id": "arrow",
"component": "Arrow"
},
{
"id": "to",
"component": "Airport",
"code": { "path": "/destination" }
},
{
"id": "meta",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["airline", "price"]
},
{
"id": "airline",
"component": "AirlineBadge",
"name": { "path": "/airline" }
},
{
"id": "price",
"component": "PriceTag",
"amount": { "path": "/price" }
},
{
"id": "bookButton",
"component": "Button",
"variant": "primary",
"child": "bookButtonLabel",
"action": {
"event": {
"name": "book_flight",
"context": {
"origin": { "path": "/origin" },
"destination": { "path": "/destination" },
"airline": { "path": "/airline" },
"price": { "path": "/price" }
}
}
}
},
{
"id": "bookButtonLabel",
"component": "Text",
"text": "Book flight"
}
]
@@ -0,0 +1,174 @@
"""
LangGraph agent for the CopilotKit Showcase (FastAPI variant).
Uses copilotkit's create_agent (wrapping langgraph) with CopilotKitMiddleware
so frontend-registered tools (useHumanInTheLoop, useFrontendTool) are properly
injected into the LLM's tool list and executed on the frontend rather than
locally.
"""
from tools import (
get_weather_impl,
query_data_impl,
schedule_meeting_impl,
manage_sales_todos_impl,
get_sales_todos_impl,
search_flights_impl,
build_a2ui_operations_from_tool_call,
)
from tools.types import SalesTodo, Flight
import json
import time
from typing import Any
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool as lc_tool
from langchain_core.messages import SystemMessage
from langchain.agents import AgentState as BaseAgentState, create_agent
from langchain.tools import ToolRuntime, tool
from langchain.messages import ToolMessage
from langgraph.types import Command
from copilotkit import CopilotKitMiddleware
class AgentState(BaseAgentState):
todos: list[SalesTodo]
@lc_tool
def get_weather(location: str):
"""Get the current weather for a location."""
return get_weather_impl(location)
@lc_tool
def query_data(query: str):
"""Query the database. Takes natural language. Always call before showing a chart."""
return query_data_impl(query)
@lc_tool
def schedule_meeting(reason: str, duration_minutes: int = 30):
"""Schedule a meeting. The user will be asked to pick a time via the UI."""
return schedule_meeting_impl(reason, duration_minutes)
@lc_tool
def search_flights(flights: list[Flight]) -> str:
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
Each flight must have: airline, airlineLogo, flightNumber, origin, destination,
date (short readable format like "Tue, Mar 18" -- use near-future dates),
departureTime, arrivalTime, duration (e.g. "4h 25m"),
status (e.g. "On Time" or "Delayed"),
statusColor (hex color for status dot),
price (e.g. "$289"), and currency (e.g. "USD").
For airlineLogo use Google favicon API:
https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
"""
result = search_flights_impl(flights)
return json.dumps(result)
@tool
def manage_sales_todos(todos: list[SalesTodo], runtime: ToolRuntime) -> Command:
"""
Manage the current sales todos. Pass the full updated list.
"""
updated = manage_sales_todos_impl(todos)
return Command(
update={
"todos": updated,
"messages": [
ToolMessage(
content="Successfully updated sales todos",
tool_call_id=runtime.tool_call_id,
)
],
}
)
@tool
def get_sales_todos(runtime: ToolRuntime):
"""
Get the current sales todos.
"""
current = runtime.state.get("todos", [])
return get_sales_todos_impl(current if current else None)
@lc_tool
def render_a2ui(
surfaceId: str,
catalogId: str,
components: list[dict],
data: dict | None = None,
) -> str:
"""Render a dynamic A2UI v0.9 surface."""
return "rendered"
@tool()
def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
"""Generate dynamic A2UI components based on the conversation.
A secondary LLM designs the UI schema and data.
"""
t0 = time.time()
messages = runtime.state["messages"][:-1]
context_entries = runtime.state.get("copilotkit", {}).get("context", [])
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
model = ChatOpenAI(model="gpt-4.1")
model_with_tool = model.bind_tools([render_a2ui], tool_choice="render_a2ui")
response = model_with_tool.invoke(
[SystemMessage(content=context_text), *messages],
)
if not response.tool_calls:
return json.dumps({"error": "LLM did not call render_a2ui"})
args = response.tool_calls[0]["args"]
result = build_a2ui_operations_from_tool_call(args)
return json.dumps(result)
model = ChatOpenAI(model="gpt-4o-mini")
SYSTEM_PROMPT = """You are a polished, professional demo assistant for CopilotKit.
Keep responses brief and clear -- 1 to 2 sentences max.
You can:
- Chat naturally with the user
- Change the UI background when asked (via frontend tool)
- Query data and render charts (via query_data tool)
- Get weather information (via get_weather tool)
- Schedule meetings with the user (via schedule_meeting tool -- the user picks a time in the UI)
- Manage sales pipeline todos (via manage_sales_todos / get_sales_todos tools)
- Search flights and display rich A2UI cards (via search_flights tool)
- Generate dynamic A2UI dashboards from conversation context (via generate_a2ui tool)
- Generate step-by-step plans for user review (human-in-the-loop)
"""
graph = create_agent(
model=model,
tools=[
get_weather,
query_data,
schedule_meeting,
search_flights,
generate_a2ui,
manage_sales_todos,
get_sales_todos,
],
middleware=[CopilotKitMiddleware()],
state_schema=AgentState,
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,120 @@
"""LangGraph agent backing the Agent Config Object demo.
Reads three forwarded properties — tone, expertise, responseLength — from the
LangGraph run's ``RunnableConfig["configurable"]["properties"]`` dict and
builds its system prompt dynamically per turn.
The CopilotKit provider's ``properties`` prop is wired through the runtime as
``forwardedProps`` on each AG-UI run. This graph reads those with defensive
defaults (unknown / missing values fall back to the defaults) and composes the
system prompt from three small rulebooks before invoking the model.
"""
from typing import Any, Literal
from langchain_core.runnables import RunnableConfig
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, MessagesState, StateGraph
_llm: ChatOpenAI | None = None
def _get_llm() -> ChatOpenAI:
"""Lazy-instantiate the LLM so importing this module (e.g. in unit tests)
does not require ``OPENAI_API_KEY`` to be set."""
global _llm
if _llm is None:
_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.4)
return _llm
Tone = Literal["professional", "casual", "enthusiastic"]
Expertise = Literal["beginner", "intermediate", "expert"]
ResponseLength = Literal["concise", "detailed"]
DEFAULT_TONE: Tone = "professional"
DEFAULT_EXPERTISE: Expertise = "intermediate"
DEFAULT_RESPONSE_LENGTH: ResponseLength = "concise"
VALID_TONES: set[str] = {"professional", "casual", "enthusiastic"}
VALID_EXPERTISE: set[str] = {"beginner", "intermediate", "expert"}
VALID_RESPONSE_LENGTHS: set[str] = {"concise", "detailed"}
def read_properties(config: RunnableConfig | None) -> dict[str, str]:
"""Read the forwarded ``properties`` object with defensive defaults.
Any missing or unrecognized value falls back to the corresponding
``DEFAULT_*`` constant. The function never raises.
"""
configurable = (config or {}).get("configurable", {}) or {}
properties = configurable.get("properties", {}) or {}
tone = properties.get("tone", DEFAULT_TONE)
expertise = properties.get("expertise", DEFAULT_EXPERTISE)
response_length = properties.get("responseLength", DEFAULT_RESPONSE_LENGTH)
if tone not in VALID_TONES:
tone = DEFAULT_TONE
if expertise not in VALID_EXPERTISE:
expertise = DEFAULT_EXPERTISE
if response_length not in VALID_RESPONSE_LENGTHS:
response_length = DEFAULT_RESPONSE_LENGTH
return {
"tone": tone,
"expertise": expertise,
"response_length": response_length,
}
def build_system_prompt(tone: str, expertise: str, response_length: str) -> str:
"""Compose the system prompt from the three axes."""
tone_rules = {
"professional": ("Use neutral, precise language. No emoji. Short sentences."),
"casual": (
"Use friendly, conversational language. Contractions OK. "
"Light humor welcome."
),
"enthusiastic": (
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
),
}
expertise_rules = {
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
"intermediate": (
"Assume common terms are understood; explain specialized terms."
),
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
}
length_rules = {
"concise": "Respond in 1-3 sentences.",
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
}
return (
"You are a helpful assistant.\n\n"
f"Tone: {tone_rules[tone]}\n"
f"Expertise level: {expertise_rules[expertise]}\n"
f"Response length: {length_rules[response_length]}"
)
def call_model(
state: MessagesState, config: RunnableConfig | None = None
) -> dict[str, Any]:
"""Single graph node — read forwarded props, build prompt, invoke LLM."""
props = read_properties(config)
system_prompt = build_system_prompt(
props["tone"], props["expertise"], props["response_length"]
)
messages = [{"role": "system", "content": system_prompt}] + state["messages"]
response = _get_llm().invoke(messages)
return {"messages": [response]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("model", call_model)
graph_builder.add_edge(START, "model")
graph_builder.add_edge("model", END)
graph = graph_builder.compile()
@@ -0,0 +1,290 @@
"""LangGraph agent backing the Beautiful Chat demo.
Verbatim port of the canonical starter at /examples/integrations/langgraph-python.
Reference structure (agent/main.py + agent/src/{todos,query,a2ui_fixed_schema,
a2ui_dynamic_schema}.py) is inlined here into a single module to match the
showcase cell's flat backend layout.
Data files (db.csv + schemas/) live alongside this module under
`beautiful_chat_data/` to keep the cell self-contained without polluting the
shared `a2ui_schemas/` directory (which is owned by a2ui_fixed.py).
"""
from __future__ import annotations
import csv
import json
import uuid
from pathlib import Path
from typing import Any, Literal, TypedDict
from copilotkit import (
CopilotKitMiddleware,
StateItem,
StateStreamingMiddleware,
a2ui,
)
from langchain.agents import AgentState as BaseAgentState
from langchain.agents import create_agent
from langchain.messages import ToolMessage
from langchain.tools import ToolRuntime, tool
from langchain_core.messages import SystemMessage
from langchain_core.tools import tool as lc_tool
from langchain_openai import ChatOpenAI
from langgraph.types import Command
# ─── Shared state schema ────────────────────────────────────────────
class Todo(TypedDict):
id: str
title: str
description: str
emoji: str
status: Literal["pending", "completed"]
class AgentState(BaseAgentState):
todos: list[Todo]
# ─── Todo tools ─────────────────────────────────────────────────────
@tool
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
"""
Manage the current todos.
"""
# Ensure all todos have IDs that are unique
for todo in todos:
if "id" not in todo or not todo["id"]:
todo["id"] = str(uuid.uuid4())
# Update the state
return Command(
update={
"todos": todos,
"messages": [
ToolMessage(
content="Successfully updated todos",
tool_call_id=runtime.tool_call_id,
)
],
}
)
@tool
def get_todos(runtime: ToolRuntime):
"""
Get the current todos.
"""
return runtime.state.get("todos", [])
todo_tools = [
manage_todos,
get_todos,
]
# ─── Data query tool ────────────────────────────────────────────────
# Read data at module load time to avoid file I/O issues in
# LangGraph Cloud's sandboxed tool execution environment.
_DATA_DIR = Path(__file__).parent / "beautiful_chat_data"
_csv_path = _DATA_DIR / "db.csv"
with open(_csv_path) as _f:
_cached_data = list(csv.DictReader(_f))
@tool
def query_data(query: str):
"""
Query the database, takes natural language. Always call before showing a chart or graph.
"""
import time
print(
f"[A2UI-DEBUG] query_data called: query='{query[:60]}' at {time.strftime('%H:%M:%S')}"
)
return _cached_data
# ─── A2UI fixed-schema tool: flight search ──────────────────────────
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
FLIGHT_SCHEMA = a2ui.load_schema(_DATA_DIR / "schemas" / "flight_schema.json")
class Flight(TypedDict):
id: str
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusIcon: str
price: str
@tool
def search_flights(flights: list[Flight]) -> str:
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
Each flight must have: id, airline (e.g. "United Airlines"),
airlineLogo (use Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
e.g. "https://www.google.com/s2/favicons?domain=united.com&sz=128" for United,
"https://www.google.com/s2/favicons?domain=delta.com&sz=128" for Delta,
"https://www.google.com/s2/favicons?domain=aa.com&sz=128" for American,
"https://www.google.com/s2/favicons?domain=alaskaair.com&sz=128" for Alaska),
flightNumber, origin, destination,
date (short readable format like "Tue, Mar 18" — use near-future dates),
departureTime, arrivalTime,
duration (e.g. "4h 25m"), status (e.g. "On Time" or "Delayed"),
statusIcon (colored dot: use "https://placehold.co/12/22c55e/22c55e.png"
for On Time, "https://placehold.co/12/eab308/eab308.png" for Delayed,
"https://placehold.co/12/ef4444/ef4444.png" for Cancelled),
and price (e.g. "$289").
"""
return a2ui.render(
operations=[
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
a2ui.update_data_model(SURFACE_ID, {"flights": flights}),
],
)
# ─── A2UI dynamic-schema tool: LLM-generated UI ─────────────────────
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
@lc_tool
def render_a2ui(
surfaceId: str,
catalogId: str,
components: list[dict],
data: dict | None = None,
) -> str:
"""Render a dynamic A2UI v0.9 surface.
Args:
surfaceId: Unique surface identifier.
catalogId: The catalog ID (use "copilotkit://app-dashboard-catalog").
components: A2UI v0.9 component array (flat format). The root
component must have id "root".
data: Optional initial data model for the surface (e.g. form values,
list items for data-bound components).
"""
return "rendered"
@tool()
def generate_a2ui(runtime: ToolRuntime[Any]) -> str:
"""Generate dynamic A2UI components based on the conversation.
A secondary LLM designs the UI schema and data. The result is
returned as an a2ui_operations container for the middleware to detect.
"""
import time
t0 = time.time()
print(f"[A2UI-DEBUG] generate_a2ui STARTED at t=0")
messages = runtime.state["messages"][:-1]
print(f"[A2UI-DEBUG] messages count: {len(messages)}")
# Get context entries from copilotkit state (catalog capabilities + component schema)
context_entries = runtime.state.get("copilotkit", {}).get("context", [])
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
print(
f"[A2UI-DEBUG] context entries: {len(context_entries)}, context_text_len: {len(context_text)}"
)
prompt = context_text
model = ChatOpenAI(model="gpt-4.1")
model_with_tool = model.bind_tools(
[render_a2ui],
tool_choice="render_a2ui",
)
print(f"[A2UI-DEBUG] calling secondary LLM at t={time.time() - t0:.1f}s")
response = model_with_tool.invoke(
[SystemMessage(content=prompt), *messages],
)
print(f"[A2UI-RESPONSE] {response}")
print(f"[A2UI-DEBUG] secondary LLM responded at t={time.time() - t0:.1f}s")
if not response.tool_calls:
print(f"[A2UI-DEBUG] ERROR: no tool calls in response")
return json.dumps({"error": "LLM did not call render_a2ui"})
tool_call = response.tool_calls[0]
args = tool_call["args"]
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
data = args.get("data", {})
print(
f"[A2UI-DEBUG] components={len(components)} data_keys={list(data.keys()) if data else []} surface={surface_id}"
)
ops = [
a2ui.create_surface(surface_id, catalog_id=catalog_id),
a2ui.update_components(surface_id, components),
]
if data:
ops.append(a2ui.update_data_model(surface_id, data))
result = a2ui.render(operations=ops)
print(
f"[A2UI-DEBUG] generate_a2ui DONE at t={time.time() - t0:.1f}s result_len={len(result)}"
)
return result
# ─── Graph ──────────────────────────────────────────────────────────
model = ChatOpenAI(model="gpt-5.4", model_kwargs={"parallel_tool_calls": False})
agent = create_agent(
model=model,
tools=[query_data, *todo_tools, generate_a2ui, search_flights],
middleware=[
CopilotKitMiddleware(),
StateStreamingMiddleware(
StateItem(state_key="todos", tool="manage_todos", tool_argument="todos")
),
],
state_schema=AgentState,
system_prompt="""
You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
Tool guidance:
- Flights: call search_flights to show flight cards with a pre-built schema.
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
charts, tables, and cards. It handles rendering automatically.
- Charts: call query_data first, then render with the chart component.
- Todos: enable app mode first, then manage todos.
- A2UI actions: when you see a log_a2ui_event result (e.g. "view_details"),
respond with a brief confirmation. The UI already updated on the frontend.
""",
)
graph = agent
@@ -0,0 +1,41 @@
date,category,subcategory,amount,type,notes
2026-01-05,Revenue,Enterprise Subscriptions,28000,income,3 new enterprise customers (Acme Corp, TechFlow, DataViz Inc)
2026-01-05,Revenue,Pro Tier Upgrades,18000,income,24 users upgraded from free to pro
2026-01-08,Revenue,API Usage Overages,9500,income,High API usage from top 5 customers
2026-01-10,Expenses,Engineering Salaries,42000,expense,7 engineers + 2 contractors
2026-01-10,Expenses,Product Team,18000,expense,PM and 2 designers
2026-01-12,Expenses,AWS Infrastructure,8200,expense,Increased compute for new AI features
2026-01-15,Expenses,Marketing - Paid Ads,12000,expense,Google Ads and LinkedIn campaigns
2026-01-18,Revenue,Consulting Services,14500,income,Custom integration for Acme Corp
2026-01-20,Expenses,Customer Success,15000,expense,3 CSMs + support tools (Intercom)
2026-01-22,Expenses,AI Model Costs,4200,expense,OpenAI API usage for product features
2026-01-25,Revenue,Marketplace Sales,12800,income,Template and plugin sales
2026-01-28,Expenses,Office & Equipment,3500,expense,New laptops and coworking spaces
2026-02-03,Revenue,Enterprise Subscriptions,31000,income,2 new customers + expansion from TechFlow
2026-02-03,Revenue,Pro Tier Upgrades,22500,income,31 upgrades + reduced churn
2026-02-05,Revenue,API Usage Overages,11800,income,DataViz Inc heavy API usage spike
2026-02-07,Expenses,Engineering Salaries,42000,expense,Same headcount as January
2026-02-07,Expenses,Product Team,18000,expense,No changes to product team
2026-02-10,Expenses,AWS Infrastructure,9500,expense,Traffic spike from viral social post
2026-02-12,Expenses,Marketing - Paid Ads,15000,expense,Increased ad spend for Q1 push
2026-02-14,Revenue,Consulting Services,18000,income,2 custom projects (TechFlow + new client)
2026-02-18,Expenses,Customer Success,16500,expense,Hired 1 additional CSM
2026-02-20,Expenses,AI Model Costs,5800,expense,Increased usage from new AI features launch
2026-02-22,Revenue,Marketplace Sales,14200,income,Top template hit featured list
2026-02-25,Expenses,Conference & Travel,4500,expense,Team attended SaaS Conference 2026
2026-02-27,Revenue,Partnership Revenue,11500,income,Referral fees from integration partners
2026-03-02,Revenue,Enterprise Subscriptions,35000,income,Major win: Fortune 500 customer signed
2026-03-02,Revenue,Pro Tier Upgrades,26000,income,42 upgrades - best month yet
2026-03-05,Revenue,API Usage Overages,13200,income,Consistent high usage across top tier
2026-03-08,Expenses,Engineering Salaries,48000,expense,Hired 1 senior engineer for AI team
2026-03-08,Expenses,Product Team,21000,expense,Promoted designer to senior level
2026-03-10,Expenses,AWS Infrastructure,11000,expense,Scaled infrastructure for enterprise client
2026-03-12,Expenses,Marketing - Paid Ads,18000,expense,Doubled down on successful campaigns
2026-03-14,Revenue,Consulting Services,21500,income,Fortune 500 onboarding + 2 other projects
2026-03-16,Expenses,Customer Success,19500,expense,Hired dedicated enterprise CSM
2026-03-18,Expenses,AI Model Costs,7200,expense,Fortune 500 client heavy AI usage
2026-03-20,Revenue,Marketplace Sales,15800,income,3 new templates in top 10
2026-03-22,Expenses,Sales & BD,12000,expense,Hired first sales rep for enterprise
2026-03-24,Revenue,Partnership Revenue,14200,income,New integration partnerships launched
2026-03-26,Expenses,Security & Compliance,6500,expense,SOC 2 audit and security tools
2026-03-28,Revenue,Training & Workshops,10200,income,Conducted 2 customer training sessions
1 date,category,subcategory,amount,type,notes
2 2026-01-05,Revenue,Enterprise Subscriptions,28000,income,3 new enterprise customers (Acme Corp, TechFlow, DataViz Inc)
3 2026-01-05,Revenue,Pro Tier Upgrades,18000,income,24 users upgraded from free to pro
4 2026-01-08,Revenue,API Usage Overages,9500,income,High API usage from top 5 customers
5 2026-01-10,Expenses,Engineering Salaries,42000,expense,7 engineers + 2 contractors
6 2026-01-10,Expenses,Product Team,18000,expense,PM and 2 designers
7 2026-01-12,Expenses,AWS Infrastructure,8200,expense,Increased compute for new AI features
8 2026-01-15,Expenses,Marketing - Paid Ads,12000,expense,Google Ads and LinkedIn campaigns
9 2026-01-18,Revenue,Consulting Services,14500,income,Custom integration for Acme Corp
10 2026-01-20,Expenses,Customer Success,15000,expense,3 CSMs + support tools (Intercom)
11 2026-01-22,Expenses,AI Model Costs,4200,expense,OpenAI API usage for product features
12 2026-01-25,Revenue,Marketplace Sales,12800,income,Template and plugin sales
13 2026-01-28,Expenses,Office & Equipment,3500,expense,New laptops and coworking spaces
14 2026-02-03,Revenue,Enterprise Subscriptions,31000,income,2 new customers + expansion from TechFlow
15 2026-02-03,Revenue,Pro Tier Upgrades,22500,income,31 upgrades + reduced churn
16 2026-02-05,Revenue,API Usage Overages,11800,income,DataViz Inc heavy API usage spike
17 2026-02-07,Expenses,Engineering Salaries,42000,expense,Same headcount as January
18 2026-02-07,Expenses,Product Team,18000,expense,No changes to product team
19 2026-02-10,Expenses,AWS Infrastructure,9500,expense,Traffic spike from viral social post
20 2026-02-12,Expenses,Marketing - Paid Ads,15000,expense,Increased ad spend for Q1 push
21 2026-02-14,Revenue,Consulting Services,18000,income,2 custom projects (TechFlow + new client)
22 2026-02-18,Expenses,Customer Success,16500,expense,Hired 1 additional CSM
23 2026-02-20,Expenses,AI Model Costs,5800,expense,Increased usage from new AI features launch
24 2026-02-22,Revenue,Marketplace Sales,14200,income,Top template hit featured list
25 2026-02-25,Expenses,Conference & Travel,4500,expense,Team attended SaaS Conference 2026
26 2026-02-27,Revenue,Partnership Revenue,11500,income,Referral fees from integration partners
27 2026-03-02,Revenue,Enterprise Subscriptions,35000,income,Major win: Fortune 500 customer signed
28 2026-03-02,Revenue,Pro Tier Upgrades,26000,income,42 upgrades - best month yet
29 2026-03-05,Revenue,API Usage Overages,13200,income,Consistent high usage across top tier
30 2026-03-08,Expenses,Engineering Salaries,48000,expense,Hired 1 senior engineer for AI team
31 2026-03-08,Expenses,Product Team,21000,expense,Promoted designer to senior level
32 2026-03-10,Expenses,AWS Infrastructure,11000,expense,Scaled infrastructure for enterprise client
33 2026-03-12,Expenses,Marketing - Paid Ads,18000,expense,Doubled down on successful campaigns
34 2026-03-14,Revenue,Consulting Services,21500,income,Fortune 500 onboarding + 2 other projects
35 2026-03-16,Expenses,Customer Success,19500,expense,Hired dedicated enterprise CSM
36 2026-03-18,Expenses,AI Model Costs,7200,expense,Fortune 500 client heavy AI usage
37 2026-03-20,Revenue,Marketplace Sales,15800,income,3 new templates in top 10
38 2026-03-22,Expenses,Sales & BD,12000,expense,Hired first sales rep for enterprise
39 2026-03-24,Revenue,Partnership Revenue,14200,income,New integration partnerships launched
40 2026-03-26,Expenses,Security & Compliance,6500,expense,SOC 2 audit and security tools
41 2026-03-28,Revenue,Training & Workshops,10200,income,Conducted 2 customer training sessions
@@ -0,0 +1,37 @@
[
{
"id": "root",
"component": "Row",
"children": {
"componentId": "flight-card",
"path": "/flights"
},
"gap": 16
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": { "path": "airline" },
"airlineLogo": { "path": "airlineLogo" },
"flightNumber": { "path": "flightNumber" },
"origin": { "path": "origin" },
"destination": { "path": "destination" },
"date": { "path": "date" },
"departureTime": { "path": "departureTime" },
"arrivalTime": { "path": "arrivalTime" },
"duration": { "path": "duration" },
"status": { "path": "status" },
"price": { "path": "price" },
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": { "path": "flightNumber" },
"origin": { "path": "origin" },
"destination": { "path": "destination" },
"price": { "path": "price" }
}
}
}
}
]
@@ -0,0 +1,104 @@
"""LangGraph agent backing the byoc-hashbrown demo (Wave 4a).
Emits hashbrown-shaped structured output that the ported HashBrownDashboard
renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) progressively
parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
Wire format
-----------
`@hashbrownai/react`'s `useJsonParser(content, kit.schema)` expects the agent
to stream a JSON object literal matching `kit.schema` — NOT the `<ui>...</ui>`
XML-style examples shown inside `useUiKit({ examples })`. Those XML examples
are the hashbrown prompt DSL that hashbrown compiles into a schema description
when driving the LLM directly (e.g. `useUiChat`/`useUiCompletion`). Because
this demo drives the LLM via langgraph instead, we must mirror what
hashbrown's own schema wire format looks like:
{
"ui": [
{ "metric": { "props": { "label": "...", "value": "..." } } },
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } },
{ "Markdown": { "props": { "children": "## heading\\nbody" } } }
]
}
Every node is a single-key object `{tagName: {props: {...}}}`. The tag names
and prop schemas match `useSalesDashboardKit()` in
`hashbrown-renderer.tsx`. `pieChart` and `barChart` receive `data` as a
JSON-encoded string (this was intentional in PR #4252 to keep the schema
stable under partial streaming).
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
You are a sales analytics assistant that replies by emitting a single JSON
object consumed by a streaming JSON parser on the frontend.
ALWAYS respond with a single JSON object of the form:
{
"ui": [
{ <componentName>: { "props": { ... } } },
...
]
}
Do NOT wrap the response in code fences. Do NOT include any preface or
explanation outside the JSON object. The response MUST be valid JSON.
Available components and their prop schemas:
- "metric": { "props": { "label": string, "value": string } }
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
- "pieChart": { "props": { "title": string, "data": string } }
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
array of {label, value} objects with at least 3 segments, e.g.
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".
- "barChart": { "props": { "title": string, "data": string } }
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
{label, value} objects with at least 3 bars, typically time-ordered.
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
raw number (no currency symbol or comma).
- "Markdown": { "props": { "children": string } }
Short explanatory text. Use for section headings and brief summaries.
Standard markdown is supported in `children`.
Rules:
- Always produce plausible sample data when the user asks for a dashboard or
chart — do not refuse for lack of data.
- Prefer 3-6 rows of data in charts; keep labels short.
- Use "Markdown" for short headings or linking sentences between visual
components. Do not emit long prose.
- Do not emit components that are not listed above.
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
Example response (sales dashboard):
{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}
"""
# Force JSON-object output mode. The frontend's `useJsonParser` bails to
# `null` on any non-JSON prefix (code fences, prose preamble, etc.), so
# leaving the model free to wander out of JSON is what left the renderer
# empty in practice. `response_format={"type": "json_object"}` tells
# OpenAI to refuse to emit anything but a single JSON object, which
# aligns the wire-level contract with what the parser accepts.
graph = create_agent(
model=ChatOpenAI(
model="gpt-4o-mini",
model_kwargs={"response_format": {"type": "json_object"}},
),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=BYOC_HASHBROWN_SYSTEM_PROMPT,
)
@@ -0,0 +1,155 @@
"""LangGraph agent backing the BYOC json-render demo.
Emits a single JSON object shaped like `@json-render/react`'s flat spec
format (`{ root, elements }`) so the frontend can feed it directly into
`<Renderer />` against a Zod-validated catalog of three components —
MetricCard, BarChart, PieChart.
The scenario mirrors Wave 4a (hashbrown) so the two BYOC rows on the
dashboard are directly comparable. The only difference is the rendering
technology; the catalog shape and suggestion prompts are identical.
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
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.
"""
graph = create_agent(
model=ChatOpenAI(
model="gpt-4o-mini",
temperature=0.2,
model_kwargs={"response_format": {"type": "json_object"}},
),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT.strip(),
)
@@ -0,0 +1,17 @@
"""LangGraph agent backing the Chat Customization (CSS) demo.
The demo is about CSS — the agent has no custom tools or behavior.
CopilotKitMiddleware is attached so CopilotKit-specific context is
picked up if the frontend ever registers suggestions/components.
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt="You are a helpful, concise assistant.",
)
@@ -0,0 +1,32 @@
"""LangGraph agent backing the Frontend Tools (Async) demo.
This cell demonstrates `useFrontendTool` with an ASYNC handler. The
frontend registers a `query_notes` tool whose handler awaits a simulated
client-side DB query (500ms latency) and returns matching notes. The
agent uses the returned result to summarize what it found.
Like the sibling `frontend_tools` cell, the backend graph registers no
tools of its own — CopilotKit forwards the frontend tool schema(s) to
the agent at runtime, and the handler executes in the browser.
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
SYSTEM_PROMPT = (
"You are a helpful assistant that can search the user's personal notes. "
"When the user asks about their notes, call the `query_notes` tool with "
"a concise keyword extracted from their request. The tool is provided "
"by the frontend at runtime and runs entirely in the user's browser — "
"you do not need to implement it yourself. After the tool returns, "
"summarize the matching notes clearly and concisely. If no notes match, "
"say so plainly and offer to try a different keyword."
)
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,86 @@
"""LangGraph agent backing the Headless Chat (Complete) demo.
The cell exists to prove that every CopilotKit rendering surface works
when the chat UI is composed manually (no <CopilotChatMessageView /> or
<CopilotChatAssistantMessage />). To exercise those surfaces we give
this agent:
- two mock backend tools (get_weather, get_stock_price) — render via
app-registered `useRenderTool` renderers on the frontend,
- access to a frontend-registered `useComponent` tool
(`highlight_note`) — the agent "calls" it and the UI flows through
the same `useRenderToolCall` path,
- MCP Apps wired through the runtime — the agent can invoke Excalidraw
MCP tools and the middleware emits activity events that
`useRenderActivityMessage` picks up.
The system prompt nudges the model toward the right surface per user
question and falls back to plain text otherwise.
"""
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
SYSTEM_PROMPT = (
"You are a helpful, concise assistant wired into a headless chat "
"surface that demonstrates CopilotKit's full rendering stack. Pick the "
"right surface for each user question and fall back to plain text when "
"none of the tools fit.\n\n"
"Routing rules:\n"
" - If the user asks about weather for a place, call `get_weather` "
"with the location.\n"
" - If the user asks about a stock or ticker (AAPL, TSLA, MSFT, ...), "
"call `get_stock_price` with the ticker.\n"
" - If the user asks you to highlight, flag, or mark a short note or "
"phrase, call the frontend `highlight_note` tool with the text and a "
"color (yellow, pink, green, or blue). Do NOT ask the user for the "
"color — pick a sensible one if they didn't say.\n"
" - If the user asks to draw, sketch, or diagram something, use the "
"Excalidraw MCP tools that are available to you.\n"
" - Otherwise, reply in plain text.\n\n"
"After a tool returns, write one short sentence summarizing the "
"result. Never fabricate data a tool could provide."
)
@tool
def get_weather(location: str) -> dict:
"""Get the current weather for a given location.
Returns a mock payload with city, temperature in Fahrenheit, humidity,
wind speed, and conditions. Use this whenever the user asks about
weather anywhere.
"""
return {
"city": location,
"temperature": 68,
"humidity": 55,
"wind_speed": 10,
"conditions": "Sunny",
}
@tool
def get_stock_price(ticker: str) -> dict:
"""Get a mock current price for a stock ticker.
Returns a payload with the ticker symbol (uppercased), price in USD,
and percentage change for the day. Use this whenever the user asks
about a stock price.
"""
return {
"ticker": ticker.upper(),
"price_usd": 189.42,
"change_pct": 1.27,
}
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[get_weather, get_stock_price],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,61 @@
"""LangGraph agent backing the In-App HITL (frontend-tool + popup) demo.
The agent is a support assistant that processes customer-care requests
(refunds, account changes, escalations). Any action that materially
affects a customer MUST be confirmed by the human operator via the
frontend-provided `request_user_approval` tool.
The tool is defined on the frontend via `useFrontendTool` with an async
handler that opens a modal dialog OUTSIDE the chat surface. The handler
awaits the user's decision and resolves with
`{"approved": bool, "reason": str}`. This agent treats that result as
authoritative: if `approved` is `True`, continue; otherwise, stop and
explain the decision back to the user.
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
SYSTEM_PROMPT = (
"You are a support operations copilot working alongside a human operator "
"inside an internal support console. The operator can see a list of open "
"support tickets on the left side of their screen and is chatting with "
"you on the right.\n"
"\n"
"Whenever the operator asks you to take an action that affects a "
"customer — for example: issuing a refund, updating a customer's plan, "
"cancelling a subscription, escalating a ticket, or sending an apology "
"credit — you MUST first call the frontend-provided "
"`request_user_approval` tool to obtain the operator's explicit consent.\n"
"\n"
"How to use `request_user_approval`:\n"
"- `message`: a short, plain-English summary of the exact action you "
" are about to take, including concrete numbers (e.g. '$50 refund to "
" customer #12345').\n"
"- `context`: optional extra context the operator might want to review "
" (the ticket ID, the policy rule you're applying, etc.). Keep it to "
" one or two short sentences.\n"
"\n"
"The tool returns an object of the shape "
'`{"approved": boolean, "reason": string | null}`.\n'
"- If `approved` is `true`: confirm in one short sentence that you are "
" processing the action. You do not actually need to call any other "
" tool — this is a demo. Just acknowledge.\n"
"- If `approved` is `false`: acknowledge the rejection in one short "
" sentence and, if `reason` is non-empty, reflect the operator's "
" reason back to them. Do NOT retry the action.\n"
"\n"
"Keep all chat replies to one or two short sentences. Never make up "
"customer data — always use whatever the operator told you in the "
"prompt."
)
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,22 @@
"""LangGraph agent backing the In-Chat HITL (useHumanInTheLoop) demo.
The `book_call` tool is defined on the frontend via `useHumanInTheLoop`,
so there is no backend tool here. CopilotKitMiddleware is attached so the
frontend suggestions and the time-picker render hook are picked up.
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=(
"You help users book an onboarding call with the sales team. "
"When they ask to book a call, call the frontend-provided "
"`book_call` tool with a short topic and the user's name. "
"Keep any chat reply to one short sentence."
),
)
@@ -0,0 +1,25 @@
"""LangGraph agent backing the HITL step-selection demo (/demos/hitl).
Minimal neutral assistant with no backend tools — frontend-registered
tools (useHumanInTheLoop's `generate_task_steps`) are injected via
CopilotKitMiddleware at runtime. Mirrors the langgraph-python reference's
`main.py` (sample_agent) pattern: tools=[], middleware only.
The heavy `sample_agent` (agent.py) defines 7+ backend tools and a custom
AgentState with `todos: list[SalesTodo]`. Routing the HITL step-selection
demo through that graph risks state-schema mismatches and tool-dispatch
contention when the only tool the demo needs is the frontend-injected
`generate_task_steps`. This dedicated graph eliminates that surface area.
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt="You are a helpful, concise assistant.",
)
@@ -0,0 +1,69 @@
"""LangGraph agent for the Interrupt-based Generative UI demo.
Defines a backend tool `schedule_meeting(topic, attendee)` that uses
langgraph's `interrupt()` primitive to pause the run and surface the
meeting context to the frontend. The frontend `useInterrupt` renderer
shows a time picker and resolves with `{chosen_time, chosen_label}` or
`{cancelled: true}`, which this tool turns into a human-readable result.
"""
# @region[backend-interrupt-tool]
from __future__ import annotations
from typing import Any, Optional
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.types import interrupt
from copilotkit import CopilotKitMiddleware
SYSTEM_PROMPT = (
"You are a scheduling assistant. Whenever the user asks you to book a "
"call / schedule a meeting, you MUST call the `schedule_meeting` tool. "
"Pass a short `topic` describing the purpose and `attendee` describing "
"who the meeting is with. After the tool returns, confirm briefly "
"whether the meeting was scheduled and at what time, or that the user "
"cancelled."
)
@tool
def schedule_meeting(topic: str, attendee: Optional[str] = None) -> str:
"""Ask the user to pick a time slot for a call, via an in-chat picker.
Args:
topic: Short human-readable description of the call's purpose.
attendee: Who the call is with (optional).
Returns:
Human-readable result string describing the chosen slot or
indicating the user cancelled.
"""
# langgraph's `interrupt()` pauses execution and forwards the payload to
# the client. The frontend v2 `useInterrupt` hook renders the picker and
# calls `resolve(...)` with the user's selection, which comes back here.
response: Any = interrupt({"topic": topic, "attendee": attendee})
if isinstance(response, dict):
if response.get("cancelled"):
return f"User cancelled. Meeting NOT scheduled: {topic}"
chosen_label = response.get("chosen_label") or response.get("chosen_time")
if chosen_label:
return f"Meeting scheduled for {chosen_label}: {topic}"
return f"User did not pick a time. Meeting NOT scheduled: {topic}"
# @endregion[backend-interrupt-tool]
model = ChatOpenAI(model="gpt-4o-mini")
graph = create_agent(
model=model,
tools=[schedule_meeting],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,63 @@
"""
LangGraph agent for the CopilotKit MCP Apps demo.
This agent has no bespoke tools — the CopilotKit runtime is wired with
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
server (see ``src/app/api/copilotkit-mcp-apps/route.ts``). The runtime
auto-applies the MCP Apps middleware, which exposes the remote MCP
server's tools to this agent at request time and emits the activity
events that CopilotKit's built-in ``MCPAppsActivityRenderer`` renders in
the chat as a sandboxed iframe.
Reference:
https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
SYSTEM_PROMPT = """\
You draw simple diagrams in Excalidraw via the MCP tool.
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
for polish. Target: one tool call, done in seconds.
When the user asks for a diagram:
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
an optional title text.
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
3. Connect with arrows. Endpoints can be element centers or simple
coordinates — you don't need edge anchors / fixedPoint bindings.
4. Include ONE `cameraUpdate` at the END of the elements array that
frames the whole diagram. Use an approved 4:3 size (600x450 or
800x600). No opening camera needed.
5. Reply with ONE short sentence describing what you drew.
Every element needs a unique string `id` (e.g. `"b1"`, `"a1"`,
`"title"`). Standard sizes: rectangles 160x70, ellipses/diamonds
120x80, 40-80px gap between shapes.
Do NOT:
- Call `read_me`. You already know the basic shape API.
- Make multiple `create_view` calls.
- Iterate or refine. Ship on the first shot.
- Add decorative colors / fills / zone backgrounds unless the user
explicitly asks for them.
- Add labels on arrows unless crucial.
If the user asks for something specific (colors, more elements,
particular layout), follow their lead — but still in ONE call.
"""
graph = create_agent(
# gpt-4o-mini for speed — Excalidraw element emission is simple
# JSON and we're biasing hard toward sub-30s generation. A faster
# model produces shorter, quicker outputs with acceptable layouts.
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,257 @@
"""Multimodal LangGraph agent — accepts image + document (PDF) attachments.
Wave 2b design: a *dedicated* vision-capable graph scoped to the
`/demos/multimodal` cell. Other demos continue to use their own (cheaper,
text-only) models — this keeps vision cost isolated to the one demo that
exercises it.
Wire format the agent sees
==========================
Attachments arrive here after travelling through:
CopilotChat → AG-UI message content parts → @ag-ui/langgraph runtime
(ag-ui → LangChain converter)
→ this agent (LangChain HumanMessage content parts)
The ag-ui-langgraph converter only understands the legacy
``{ type: "binary", mimeType, data | url }`` AG-UI part shape — the page
at ``src/app/demos/multimodal/page.tsx`` installs an
``onRunInitialized`` shim that rewrites the modern
``{ type: "image" | "document", source: {...} }`` shape CopilotChat emits
to the legacy shape before it hits the runtime. Once the converter has
run, every attachment shows up in this agent as a LangChain
``image_url`` content part::
{"type": "image_url", "image_url": {"url": "data:<mime>;base64,<payload>"}}
regardless of whether the upstream modality was ``image`` or ``document``.
We therefore route on ``mimeType``, not the part ``type``:
``image/*`` parts are forwarded to GPT-4o unchanged (vision-native);
``application/pdf`` parts are flattened to inline text via ``pypdf`` so
the model can read them without needing file-part support.
References:
- src/agents/main.py, src/agents/agentic_chat.py (baseline pattern)
- packages/runtime/src/agent/converters/tanstack.ts (the modern content-
part shape — useful context when the runtime gets upgraded and this
agent can drop the pypdf flatten)
"""
from __future__ import annotations
import base64
import io
from typing import Any
from copilotkit import CopilotKitMiddleware
from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
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."
)
def _extract_data_url_parts(url: str) -> tuple[str, str]:
"""Split a ``data:<mime>;base64,<payload>`` URL into (mime, base64-payload).
Returns ("", url) if the input is not a base64 data URL — callers can
fall back to treating the url as a fetchable reference.
"""
if not url.startswith("data:"):
return "", url
header, _, payload = url.partition(",")
# Header looks like "data:application/pdf;base64" — take the piece
# between the colon and the first semicolon.
if ":" not in header:
return "", payload
meta = header.split(":", 1)[1]
mime = meta.split(";", 1)[0] if ";" in meta else meta
return mime, payload
def _extract_pdf_text(b64: str) -> str:
"""Decode an inline-base64 PDF and extract its text.
Returns an empty string if decoding or extraction fails — callers must
treat the extracted text as best-effort. Any exception here is logged
and swallowed so one malformed attachment does not tank the whole
user turn.
"""
try:
raw = base64.b64decode(b64, validate=False)
except Exception as exc: # pragma: no cover - defensive
print(f"[multimodal_agent] base64 decode failed: {exc}")
return ""
try:
# Lazy import — keeps the module importable even if pypdf is missing
# at dev-server boot (we only need it when a PDF actually arrives).
from pypdf import PdfReader # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover - defensive
print(
"[multimodal_agent] pypdf not installed — PDF text extraction "
f"unavailable: {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: # pragma: no cover - defensive
print(f"[multimodal_agent] pypdf extraction failed: {exc}")
return ""
def _classify_attachment_part(part: Any) -> tuple[str, str, str] | None:
"""Inspect a content part and return (kind, mime, base64_payload).
``kind`` is one of ``"image"``, ``"pdf"``, ``"other"``. Returns
``None`` if the part is not an attachment we recognise (plain text,
unrelated dict, string, etc.).
Handles the shapes we actually see in practice:
- ``{"type": "image_url", "image_url": {"url": "data:..."}}``
(what the ag-ui-langgraph converter emits for every attachment
after the page rewrites to legacy ``binary``).
- ``{"type": "image_url", "image_url": "data:..."}``
(older LangChain/OpenAI shape where ``image_url`` is a raw string).
- ``{"type": "document", "source": {"type": "data",
"value": "<base64>", "mimeType": "application/pdf"}}``
(modern AG-UI shape — preserved for forward-compat if the runtime
ever starts forwarding modern parts directly).
"""
if not isinstance(part, dict):
return None
part_type = part.get("type")
if part_type == "image_url":
image_url = part.get("image_url")
url: str | None = None
if isinstance(image_url, str):
url = image_url
elif isinstance(image_url, dict):
raw_url = image_url.get("url")
if isinstance(raw_url, str):
url = raw_url
if not url:
return None
mime, payload = _extract_data_url_parts(url)
if not payload or not mime:
return None
if mime.startswith("image/"):
return ("image", mime, payload)
if "pdf" in mime.lower():
return ("pdf", mime, payload)
return ("other", mime, payload)
if part_type == "document":
source = part.get("source")
if not isinstance(source, dict) or source.get("type") != "data":
return None
value = source.get("value")
mime = source.get("mimeType", "")
if not isinstance(value, str) or not isinstance(mime, str):
return None
if "pdf" in mime.lower():
return ("pdf", mime, value)
return ("other", mime, value)
return None
def _preprocess_part(part: Any) -> Any:
"""Flatten PDF attachments to text; pass everything else through.
Images stay as-is so GPT-4o consumes them natively via its vision
adapter. PDFs (which gpt-4o cannot read directly) become a text part
prefixed with ``[Attached document]`` and the extracted body. If
extraction fails we emit a structured placeholder so the model can
tell the user the document was unreadable instead of pretending no
attachment was sent.
"""
classified = _classify_attachment_part(part)
if classified is None:
return part
kind, _mime, payload = classified
if kind != "pdf":
return part
text = _extract_pdf_text(payload)
if not text:
return {
"type": "text",
"text": "[Attached document: PDF could not be read.]",
}
return {"type": "text", "text": f"[Attached document]\n{text}"}
def _rewrite_messages(messages: list[Any]) -> list[Any]:
"""Rewrite user messages so non-image attachments become text parts.
Operates on the messages list stored in agent state. Returns a *new*
list; the input list is not mutated.
"""
rewritten: list[Any] = []
for message in messages:
# Only touch HumanMessage — assistant/tool messages stay as-is.
if not isinstance(message, HumanMessage):
rewritten.append(message)
continue
content = message.content
if not isinstance(content, list):
rewritten.append(message)
continue
new_parts = [_preprocess_part(part) for part in content]
rewritten.append(HumanMessage(content=new_parts, id=message.id))
return rewritten
class _PdfFlattenMiddleware(AgentMiddleware):
"""Flatten PDF content parts to text before the model call.
We run this in ``before_model`` so every model invocation — including
retries after tool calls — sees the flattened view. The middleware is
idempotent: once a part has been rewritten to ``{"type": "text", ...}``
it is returned unchanged on subsequent passes.
"""
def before_model(self, state, runtime): # type: ignore[override]
del runtime # unused
messages = state.get("messages") if isinstance(state, dict) else None
if not messages:
return None
rewritten = _rewrite_messages(messages)
# Only emit a patch if anything actually changed — avoids a
# superfluous state update on every model hop.
if rewritten == messages:
return None
return {"messages": rewritten}
# Vision-capable model. gpt-4o consumes `image_url` content parts natively.
_MODEL = ChatOpenAI(model="gpt-4o", temperature=0.2)
graph = create_agent(
model=_MODEL,
tools=[],
middleware=[_PdfFlattenMiddleware(), CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
# Re-export under both names — `graph` matches the langgraph.json convention
# used by the rest of the package; `multimodal_agent` is a friendlier alias
# for any future non-langgraph.json import paths.
multimodal_agent = graph
__all__ = ["graph", "multimodal_agent"]
@@ -0,0 +1,90 @@
"""LangGraph agent for the Open-Ended Generative UI (Advanced) demo.
This is the "advanced" variant of the Open Generative UI demo. The key
distinguishing feature: the agent-authored, sandboxed UI can invoke
frontend-registered **sandbox functions** — functions the app defines on
the host page (see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`)
and makes callable from inside the iframe via
`await Websandbox.connection.remote.<name>(args)`.
How it works end-to-end:
- The frontend passes `openGenerativeUI={{ sandboxFunctions }}` to the
`CopilotKitProvider`. The provider injects a JSON descriptor of those
functions into the agent context.
- `CopilotKitMiddleware` here picks up both the frontend-registered
`generateSandboxedUi` tool (auto-registered by the provider when OGUI
is enabled on the runtime) AND the sandbox-function descriptors (via
`copilotkit.context`), and merges them into what the LLM sees.
- The LLM then generates HTML + JS that calls
`Websandbox.connection.remote.<name>(...)` in response to user
interactions.
- The runtime's `OpenGenerativeUIMiddleware` converts the streaming
`generateSandboxedUi` tool call into `open-generative-ui` activity
events that the built-in renderer mounts inside a sandboxed iframe.
- The renderer wires each `sandboxFunctions` entry as a `localApi`
method on the websandbox connection so in-iframe code can call it.
The "minimal" sibling (`open_gen_ui_agent.py`) uses the same OGUI
pipeline without sandbox functions.
"""
from __future__ import annotations
from copilotkit import CopilotKitMiddleware
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
SYSTEM_PROMPT = """You are a UI-generating assistant for the Open Generative UI (Advanced) demo.
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. The generated UI must be INTERACTIVE and must invoke the
available host-side sandbox functions described in your agent context
(delivered via `copilotkit.context`) in response to user interactions.
Sandbox-function calling contract (inside the generated iframe):
- Call a host function with:
await Websandbox.connection.remote.<functionName>(args)
The call returns a Promise; await it.
- Each handler returns a plain object. Read the return shape from the
function's description in your context and use the EXACT field names
it returns (e.g. if the description says the handler returns
`{ ok, value }`, read `res.value` — not `res.result`).
- Descriptions, names, and JSON-schema parameter shapes for every
available sandbox function are listed in your context. Read them
carefully and wire at least one interactive UI element to call one.
Sandbox iframe restrictions (CRITICAL):
- The iframe runs with `sandbox="allow-scripts"` ONLY. Forms are NOT
allowed. You MUST NOT use `<form>` elements or `<button type="submit">`.
Clicking a submit button inside a sandboxed form is blocked by the
browser BEFORE any onsubmit handler runs, so the sandbox-function call
never fires.
- Use plain `<button type="button">` elements and wire them with
`addEventListener('click', ...)` or an inline click handler. Do the same
for "Enter" keypresses on inputs: attach a `keydown` listener that
checks `e.key === 'Enter'` and calls your handler directly — do NOT
wrap inputs in a `<form>`.
Generation guidance:
- Emit `initialHeight` and `placeholderMessages` first, then CSS, then
HTML, then `jsFunctions` / `jsExpressions` if helpful.
- Always include a visible result element (e.g. an output div) that you
UPDATE after the sandbox function resolves, so the user can *see* the
round-trip: "Button clicked -> remote call -> visible result".
- Use CDN scripts (Chart.js, D3, etc.) via <script> tags in the HTML head
when you need libraries.
- Do NOT use fetch/XHR, localStorage, or document.cookie — the sandbox has
no same-origin access. ONLY use `Websandbox.connection.remote.*` for
host-page interactions.
- Keep your own chat message brief (1 sentence max); the rendered UI is
the real output.
"""
graph = create_agent(
model=ChatOpenAI(model="gpt-4.1", model_kwargs={"parallel_tool_calls": False}),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,65 @@
"""Minimal LangGraph agent for the Open-Ended Generative UI demo.
The simplest possible example that exercises the open-ended generative UI
pipeline. All the interesting work happens outside the agent:
- `CopilotKitMiddleware` merges the frontend-registered `generateSandboxedUi`
tool (auto-registered by `CopilotKitProvider` when the runtime has
`openGenerativeUI` enabled) into the agent's tool list. The LLM then sees
the tool via the normal AG-UI flow.
- When the LLM calls `generateSandboxedUi`, the runtime's
`OpenGenerativeUIMiddleware` (enabled via `openGenerativeUI` on the
runtime — see `src/app/api/copilotkit-ogui/route.ts`) converts that
streaming tool call into `open-generative-ui` activity events that the
built-in renderer mounts inside a sandboxed iframe.
This is the minimal variant: no sandbox functions, no app-side tools. The
agent simply asks the LLM to design and emit a single-shot sandboxed UI.
The "advanced" sibling (`open_gen_ui_advanced_agent.py`) builds on this
with sandbox-to-host function calling via `openGenerativeUI.sandboxFunctions`.
"""
from __future__ import annotations
from copilotkit import CopilotKitMiddleware
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
SYSTEM_PROMPT = """You are a UI-generating assistant for an Open Generative UI demo
focused on intricate, educational visualisations (3D axes / rotations,
neural-network activations, sorting-algorithm walkthroughs, Fourier
series, wave interference, planetary orbits, etc.).
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. Design a visually polished, self-contained HTML + CSS +
SVG widget that *teaches* the requested concept.
The frontend injects a detailed "design skill" as agent context
describing the palette, typography, labelling, and motion conventions
expected — follow it closely. Key invariants:
- Use inline SVG (or <canvas>) for geometric content, not stacks of <div>s.
- Every axis is labelled; every colour-coded series has a legend.
- Prefer CSS @keyframes / transitions over setInterval; loop cyclical
concepts with animation-iteration-count: infinite.
- Motion must teach — animate the actual step of the concept, not decoration.
- No fetch / XHR / localStorage — the sandbox has no same-origin access.
Output order:
- `initialHeight` (typically 480-560 for visualisations) first.
- A short `placeholderMessages` array (2-3 lines describing the build).
- `css` (complete).
- `html` (streams live — keep it tidy). CDN <script> tags for Chart.js /
D3 / etc. go inside the html.
Keep your own chat message brief (1 sentence) — the real output is the
rendered visualisation.
"""
graph = create_agent(
model=ChatOpenAI(model="gpt-4.1", model_kwargs={"parallel_tool_calls": False}),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,33 @@
"""LangGraph agent backing the Shared State (Agent Read-Only) demo.
Demonstrates the `useAgentContext` hook from @copilotkit/react-core/v2:
the frontend provides READ-ONLY context *to* the agent. This is the
reverse direction of writable-shared-state — the UI cannot be edited by
the agent, but the agent reads this context on every turn via
`CopilotKitMiddleware`, which routes the context entries into the
model's message history.
No custom state, no tools: this is the minimal shape of the
useAgentContext pattern. The agent just reads whatever context the
frontend registered and answers accordingly.
"""
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt=(
"You are a helpful, concise assistant. The frontend may provide "
"read-only context about the user (e.g. name, timezone, recent "
"activity) via the `useAgentContext` hook. Always consult that "
"context when it is relevant — address the user by name if known, "
"respect their timezone when mentioning times, and reference "
"recent activity when it helps you answer. Keep responses short."
),
)
@@ -0,0 +1,48 @@
"""Reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
Shared by agentic-chat-reasoning (custom amber ReasoningBlock) and
reasoning-default-render (CopilotKit's built-in reasoning slot).
Why a reasoning model + Responses API:
The OpenAI Responses API streams `response.reasoning_summary_text.delta`
items only for native reasoning models (gpt-5, o3, o4-mini, etc.).
CopilotKit's bridge translates those into AG-UI REASONING_MESSAGE_*
events with `role: "reasoning"`, which the frontend renders via the
`reasoningMessage` slot. gpt-4o / gpt-4o-mini do not emit reasoning
items, so a non-reasoning model would never light up the slot.
"""
from __future__ import annotations
import os
from deepagents import create_deep_agent
from langchain.chat_models import init_chat_model
from src.agents.src._header_forwarding_middleware import HeaderForwardingMiddleware
SYSTEM_PROMPT = (
"You are a helpful assistant. For each user question, first think "
"step-by-step about the approach, then give a concise answer."
)
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5-mini")
# No full CopilotKitMiddleware — this demo exercises only reasoning-token
# streaming through the OpenAI Responses API and doesn't consume frontend
# tools or app context. We still attach the minimal HeaderForwardingMiddleware
# so the inbound ``x-aimock-context`` (and other ``x-*``) headers reach the
# outgoing /v1/responses call; without it the LangGraph run swallows them
# inside ``configurable`` and aimock 404s with no fixture match. The minimal
# middleware does ONLY header propagation — no App-Context injection, no
# tool-merging, no state-surfacing. Mirrors langgraph-python's reasoning agent.
graph = create_deep_agent(
model=init_chat_model(
f"openai:{REASONING_MODEL}",
use_responses_api=True,
reasoning={"effort": "low", "summary": "auto"},
),
tools=[],
system_prompt=SYSTEM_PROMPT,
middleware=[HeaderForwardingMiddleware()],
)
@@ -0,0 +1,74 @@
"""LangGraph (FastAPI) agent for the A2UI Error Recovery demo (OSS-158 / OSS-375).
Same dynamic-schema A2UI setup as `a2ui_dynamic.py` (declarative-gen-ui), but
with the toolkit's validate->retry recovery loop made *visible*. The two aimock
pills drive the inner `render_a2ui` sub-agent two ways:
- HEAL pill: the model emits FREE-FORM / sloppy A2UI args (components and data
as JSON strings rather than structured arrays) — the toolkit heals them via
`parse_and_fix` into a valid surface in a single pass, which paints.
- EXHAUST pill: every attempt is structurally invalid (the root references a
missing child), so the validate->retry loop hits the cap and the tool
returns the `a2ui_recovery_exhausted` hard-fail envelope, which the renderer
surfaces as a tasteful `failed` state (no broken surface).
Backend-owned wiring: unlike the declarative-gen-ui demo (which relies on the
CopilotKit runtime auto-injecting `generate_a2ui`), this agent OWNS the tool via
`ag_ui_langgraph.get_a2ui_tools`, whose body runs the `render_a2ui` sub-agent +
the toolkit recovery loop IN-GRAPH. The dedicated route sets
`injectA2UITool: false` so the runtime does not inject a second copy.
Mirrors `showcase/integrations/langgraph-python/src/agents/recovery_agent.py`.
Catalog is reused from declarative-gen-ui ("declarative-gen-ui-catalog"); the
Vantage Threads sales dataset + composition rules arrive from the frontend via
App Context (declarative-gen-ui/sales-context.ts).
"""
from __future__ import annotations
import logging
from copilotkit import CopilotKitMiddleware
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from ag_ui_langgraph import get_a2ui_tools
logger = logging.getLogger(__name__)
def _log_attempt(record: dict) -> None:
"""Dev observability: log each recovery attempt (incl. rejected ones)."""
logger.info(
"[a2ui recovery] attempt %s: %s %s",
record.get("attempt"),
"valid" if record.get("ok") else "invalid",
record.get("errors"),
)
SYSTEM_PROMPT = (
"You are the embedded sales analyst for Vantage Threads, the fictional "
"B2B apparel company described in your App Context. Answer every business "
"question by calling `generate_a2ui` to draw a rich visual surface, and "
"keep the chat reply to one short sentence. Ground every number in the "
"sales dataset from your App Context. `generate_a2ui` handles the "
"rendering — and its automatic recovery — for you."
)
_MODEL = "gpt-4.1"
graph = create_agent(
model=ChatOpenAI(model=_MODEL),
tools=[
get_a2ui_tools(
{
"model": ChatOpenAI(model=_MODEL),
"default_catalog_id": "declarative-gen-ui-catalog",
"recovery": {"maxAttempts": 3},
"on_a2ui_attempt": _log_attempt,
}
)
],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,158 @@
"""LangGraph agent backing the Shared State (Read + Write) demo.
Demonstrates the full bidirectional shared-state pattern between UI and
agent:
- **UI -> agent (write)**: The UI owns a `preferences` object (the user's
profile) that it writes into agent state via `agent.setState(...)`. A
middleware reads those preferences every turn and injects them into
the system prompt, so the LLM adapts accordingly.
- **agent -> UI (read)**: The agent can call `set_notes` to update a
`notes` slot in shared state. The UI reflects every update in real
time via `useAgent(...)`.
Together this shows the canonical LangGraph bidirectional shared
state: frontend writes, backend reads AND writes, frontend re-renders.
This is the FastAPI variant — the graph is exported and registered in
`langgraph.json`. The FastAPI server (langgraph-cli) hosts the graph;
the Next.js runtime route bridges the CopilotKit AG-UI protocol to the
LangGraph deployment.
"""
from typing import Any, Awaitable, Callable, TypedDict
from langchain.agents import AgentState as BaseAgentState, create_agent
from langchain.agents.middleware import (
AgentMiddleware,
ModelRequest,
ModelResponse,
)
from langchain.tools import ToolRuntime, tool
from langchain_core.messages import SystemMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langgraph.types import Command
from copilotkit import CopilotKitMiddleware
class Preferences(TypedDict, total=False):
name: str
tone: str # "formal" | "casual" | "playful"
language: str # "English", "Spanish", ...
interests: list[str]
class AgentState(BaseAgentState):
"""Bidirectional shared state between UI and agent.
- `preferences` is written by the UI (via agent.setState).
- `notes` is written by the agent (via the `set_notes` tool) and
read by the UI.
"""
preferences: Preferences
notes: list[str]
@tool
def set_notes(notes: list[str], runtime: ToolRuntime) -> Command:
"""Replace the notes array in shared state with the full updated list.
Use this tool whenever the user asks you to "remember" something, or
when you have an observation about the user worth surfacing in the
UI's notes panel. Always pass the FULL notes list (existing notes +
any new ones), not a diff. Keep each note short (< 120 chars).
"""
return Command(
update={
"notes": notes,
"messages": [
ToolMessage(
content="Notes updated.",
tool_call_id=runtime.tool_call_id,
)
],
}
)
class PreferencesInjectorMiddleware(AgentMiddleware[AgentState, Any]):
"""Injects the UI-supplied `preferences` into the system prompt.
Every turn, we read the latest `preferences` from agent state and
prepend a SystemMessage that tells the LLM about them. This is how
UI-written state becomes visible to the agent.
"""
state_schema = AgentState
@property
def name(self) -> str:
return "PreferencesInjectorMiddleware"
def _build_prefs_message(self, prefs: Preferences) -> SystemMessage | None:
if not prefs:
return None
lines = ["The user has shared these preferences with you:"]
if prefs.get("name"):
lines.append(f"- Name: {prefs['name']}")
if prefs.get("tone"):
lines.append(f"- Preferred tone: {prefs['tone']}")
if prefs.get("language"):
lines.append(f"- Preferred language: {prefs['language']}")
interests = prefs.get("interests") or []
if interests:
lines.append(f"- Interests: {', '.join(interests)}")
# If `prefs` is non-empty but contains only unknown keys or empty
# values, no payload lines were appended — emitting just the
# header + trailer would lie to the agent ("preferences exist")
# when in fact none do. Bail out instead.
if len(lines) == 1:
return None
lines.append(
"Tailor every response to these preferences. Address the user "
"by name when appropriate."
)
return SystemMessage(content="\n".join(lines))
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
prefs = request.state.get("preferences") or {}
prefs_message = self._build_prefs_message(prefs)
if prefs_message is None:
return handler(request)
return handler(request.override(messages=[prefs_message, *request.messages]))
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
prefs = request.state.get("preferences") or {}
prefs_message = self._build_prefs_message(prefs)
if prefs_message is None:
return await handler(request)
return await handler(
request.override(messages=[prefs_message, *request.messages])
)
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[set_notes],
middleware=[CopilotKitMiddleware(), PreferencesInjectorMiddleware()],
state_schema=AgentState,
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. "
"When the user asks you to remember something, or when you observe "
"something worth surfacing in the UI, call `set_notes` with the "
"FULL updated list of short note strings (existing notes + new)."
),
)
@@ -0,0 +1,247 @@
"""LangGraph agent backing the Sub-Agents demo.
Demonstrates multi-agent delegation with a visible delegation log.
A top-level "supervisor" LLM orchestrates three specialized sub-agents,
exposed as tools:
- `research_agent` — gathers facts
- `writing_agent` — drafts prose
- `critique_agent` — reviews drafts
Each sub-agent is a full `create_agent(...)` under the hood. Every
delegation appends an entry to the `delegations` slot in shared agent
state so the UI can render a live "delegation log" as the supervisor
fans work out and collects results. This is the canonical LangGraph
sub-agents-as-tools pattern, adapted to surface delegation events to
the frontend via CopilotKit's shared-state channel.
This is the FastAPI variant — the graph is exported and registered in
`langgraph.json`. Identical agent topology to the langgraph-python
reference; only the server framework differs.
"""
# @region[supervisor-delegation-tools]
# @region[subagent-setup]
import uuid
from operator import add
from typing import Annotated, Literal, TypedDict
from langchain.agents import AgentState as BaseAgentState, create_agent
from langchain.tools import ToolRuntime, tool
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langgraph.types import Command
from copilotkit import CopilotKitMiddleware
# ---------------------------------------------------------------------------
# Shared state
# ---------------------------------------------------------------------------
class Delegation(TypedDict):
id: str
sub_agent: Literal["research_agent", "writing_agent", "critique_agent"]
task: str
status: Literal["running", "completed", "failed"]
result: str
class AgentState(BaseAgentState):
"""Shared state. `delegations` is rendered as a live log in the UI.
`delegations` uses `operator.add` as its channel reducer so concurrent
tool calls within a single supervisor turn each contribute their own
entry. Without a reducer, parallel `tool_calls` would each read the
same snapshot and the channel would last-write-wins, silently dropping
every delegation but one from the UI log.
"""
delegations: Annotated[list[Delegation], add]
# ---------------------------------------------------------------------------
# Sub-agents (real LLM agents under the hood)
# ---------------------------------------------------------------------------
# Each sub-agent is a full-fledged `create_agent(...)` with its own
# system prompt. They don't share memory or tools with the supervisor —
# the supervisor only sees their return value.
_sub_model = ChatOpenAI(model="gpt-4o-mini")
_research_agent = create_agent(
model=_sub_model,
tools=[],
system_prompt=(
"You are a research sub-agent. Given a topic, produce a concise "
"bulleted list of 3-5 key facts. No preamble, no closing."
),
)
_writing_agent = create_agent(
model=_sub_model,
tools=[],
system_prompt=(
"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_agent = create_agent(
model=_sub_model,
tools=[],
system_prompt=(
"You are an editorial critique sub-agent. Given a draft, give "
"2-3 crisp, actionable critiques. No preamble."
),
)
# @endregion[subagent-setup]
def _invoke_sub_agent(agent, task: str) -> str:
"""Run a sub-agent on `task` and return its final message content."""
result = agent.invoke({"messages": [HumanMessage(content=task)]})
messages = result.get("messages", [])
if not messages:
return ""
return str(messages[-1].content)
def _delegation_command(
sub_agent: str,
task: str,
status: Literal["completed", "failed"],
result: str,
tool_call_id: str,
) -> Command:
"""Build a Command that appends a single new delegation entry.
Emits ONLY the new entry under `delegations`. The channel reducer
(`operator.add` on `AgentState.delegations`) extends the existing
list, so parallel tool calls within one supervisor turn each
contribute their own entry instead of clobbering each other via a
last-write-wins read-modify-write.
"""
entry: Delegation = {
"id": str(uuid.uuid4()),
"sub_agent": sub_agent, # type: ignore[typeddict-item]
"task": task,
"status": status,
"result": result,
}
return Command(
update={
"delegations": [entry],
"messages": [
ToolMessage(
content=result,
tool_call_id=tool_call_id,
)
],
}
)
def _delegate(
sub_agent_name: str,
agent,
task: str,
tool_call_id: str,
) -> Command:
"""Invoke a sub-agent and turn the outcome into a Command.
Wrapped in try/except so that a sub-agent LLM failure (rate limit,
transport error, missing API key, etc.) is recorded as a `failed`
delegation entry and surfaced to the supervisor as a ToolMessage,
instead of propagating and crashing the supervisor turn. The
user-facing `result` is scrubbed to the exception class name only;
full details are captured server-side via the standard logging path
when the exception is re-raised at the caller's discretion (we do
not re-raise here — recovery is the supervisor's job).
"""
try:
result = _invoke_sub_agent(agent, task)
return _delegation_command(
sub_agent_name, task, "completed", result, tool_call_id
)
except Exception as exc: # noqa: BLE001 - intentional broad catch
# Keep the message generic; class name only, no exception args
# (which can contain prompts, keys, or other sensitive data).
message = (
f"sub-agent call failed: {exc.__class__.__name__} "
f"(see server logs for details)"
)
return _delegation_command(
sub_agent_name, task, "failed", message, tool_call_id
)
# ---------------------------------------------------------------------------
# Supervisor tools (each tool delegates to one sub-agent)
# ---------------------------------------------------------------------------
# Each @tool wraps a sub-agent invocation. The supervisor LLM "calls"
# these tools to delegate work; each call synchronously runs the
# matching sub-agent, records the delegation into shared state, and
# returns the sub-agent's output as a ToolMessage the supervisor can
# read on its next step.
@tool
def research_agent(task: str, runtime: ToolRuntime) -> Command:
"""Delegate a research task to the research sub-agent.
Use for: gathering facts, background, definitions, statistics.
Returns a bulleted list of key facts.
"""
return _delegate("research_agent", _research_agent, task, runtime.tool_call_id)
@tool
def writing_agent(task: str, runtime: ToolRuntime) -> Command:
"""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`.
"""
return _delegate("writing_agent", _writing_agent, task, runtime.tool_call_id)
@tool
def critique_agent(task: str, runtime: ToolRuntime) -> Command:
"""Delegate a critique task to the critique sub-agent.
Use for: reviewing a draft and suggesting concrete improvements.
"""
return _delegate("critique_agent", _critique_agent, task, runtime.tool_call_id)
# @endregion[supervisor-delegation-tools]
# ---------------------------------------------------------------------------
# Supervisor (the graph we export)
# ---------------------------------------------------------------------------
graph = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[research_agent, writing_agent, critique_agent],
middleware=[CopilotKitMiddleware()],
state_schema=AgentState,
system_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."
),
)
@@ -0,0 +1,146 @@
"""
LangGraph agent for the CopilotKit Tool Rendering demos.
Backs the three tool-rendering cells:
- tool-rendering-default-catchall (no frontend renderers)
- tool-rendering-custom-catchall (wildcard renderer on frontend)
- tool-rendering (per-tool + catch-all on frontend)
- tool-rendering-reasoning-chain (testing — also streams reasoning)
All cells share this backend — they differ only in how the frontend
renders the same tool calls. Kept separate from `agent.py` so the
tool-rendering demo has a tightly-scoped tool set.
"""
# @region[weather-tool-backend]
from random import choice, randint
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
# Multi-tool chaining prompt.
#
# The goal of this demo is to surface MULTIPLE tool-call cards per turn so
# the rendering patterns (per-tool + catch-all) get exercised visibly. The
# prompt nudges the model toward an explore-then-enrich pattern (e.g.
# `get_weather("Tokyo")` -> `search_flights(..., "Tokyo")`) without forcing
# a rigid recipe: we describe the *habit*, not a chain.
SYSTEM_PROMPT = (
"You are a helpful travel & lifestyle concierge. You have mock tools "
"for weather, flights, stock prices, and dice rolls - they all return "
"fake data, so call them liberally.\n\n"
"Your habit is to CHAIN tools when one answer naturally invites another. "
"For a single user question, call at least TWO tools in succession when "
"the topic allows before composing your final reply. Examples of "
"helpful chains you should default to:\n"
" - 'What's the weather in Tokyo?' -> call get_weather('Tokyo'), then "
"call search_flights(origin='SFO', destination='Tokyo') so the user "
"also sees how to get there.\n"
" - 'How is AAPL doing?' -> call get_stock_price('AAPL'), then call "
"get_stock_price on a related ticker (e.g. 'MSFT' or 'GOOGL') for "
"comparison.\n"
" - 'Roll a d20' -> call roll_dice(20), then call roll_dice again with "
"a different number of sides so the user sees a contrast.\n"
" - 'Find flights from SFO to JFK' -> call search_flights, then call "
"get_weather on the destination city.\n\n"
"Only skip chaining when the user has clearly asked for a single, "
"atomic answer and more tool calls would feel intrusive. Never "
"fabricate data that a tool could provide."
)
@tool
def get_weather(location: str) -> dict:
"""Get the current weather for a given location.
Useful on its own for weather questions, and a great companion to
`search_flights` - always consider checking the weather at a
destination the user is flying to, and checking flights to any
city whose weather the user has just asked about.
"""
return {
"city": location,
"temperature": 68,
"humidity": 55,
"wind_speed": 10,
"conditions": "Sunny",
}
# @endregion[weather-tool-backend]
@tool
def search_flights(origin: str, destination: str) -> dict:
"""Search mock flights from an origin airport to a destination airport.
Pairs naturally with `get_weather`: after searching flights, check
the weather at the destination so the user can plan. When the user
mentions a city without a matching origin, default the origin to
'SFO'.
"""
return {
"origin": origin,
"destination": destination,
"flights": [
{
"airline": "United",
"flight": "UA231",
"depart": "08:15",
"arrive": "16:45",
"price_usd": 348,
},
{
"airline": "Delta",
"flight": "DL412",
"depart": "11:20",
"arrive": "19:55",
"price_usd": 312,
},
{
"airline": "JetBlue",
"flight": "B6722",
"depart": "17:05",
"arrive": "01:30",
"price_usd": 289,
},
],
}
@tool
def get_stock_price(ticker: str) -> dict:
"""Get a mock current price for a stock ticker.
When the user asks about a single ticker, consider also pulling a
related ticker for context (e.g. if they ask about 'AAPL', also
fetch 'MSFT' or 'GOOGL' so the reply can compare).
"""
return {
"ticker": ticker.upper(),
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
}
@tool
def roll_dice(sides: int = 6) -> dict:
"""Roll a single die with the given number of sides.
When the user asks for a roll, consider rolling twice with different
numbers of sides so the reply can show a contrast (e.g. a d6 AND a
d20).
"""
return {"sides": sides, "result": randint(1, max(2, sides))}
model = ChatOpenAI(model="gpt-4o-mini")
graph = create_agent(
model=model,
tools=[get_weather, search_flights, get_stock_price, roll_dice],
middleware=[CopilotKitMiddleware()],
system_prompt=SYSTEM_PROMPT,
)
@@ -0,0 +1,101 @@
"""Tool Rendering (Reasoning Chain) — minimal deep agent with tools.
Routes through a reasoning-capable OpenAI model via the Responses API
so the chain of thought streams as AG-UI REASONING_MESSAGE_* events
alongside the tool calls. See `reasoning_agent.py` for the rationale.
"""
from __future__ import annotations
import os
from random import choice, randint
from deepagents import create_deep_agent
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from src.agents.src._header_forwarding_middleware import HeaderForwardingMiddleware
@tool
def get_weather(location: str) -> dict:
"""Get the current weather for a given location."""
return {
"city": location,
"temperature": 68,
"humidity": 55,
"wind_speed": 10,
"conditions": "Sunny",
}
@tool
def search_flights(origin: str, destination: str) -> dict:
"""Search mock flights from an origin airport to a destination airport."""
return {
"origin": origin,
"destination": destination,
"flights": [
{
"airline": "United",
"flight": "UA231",
"depart": "08:15",
"arrive": "16:45",
"price_usd": 348,
},
{
"airline": "Delta",
"flight": "DL412",
"depart": "11:20",
"arrive": "19:55",
"price_usd": 312,
},
{
"airline": "JetBlue",
"flight": "B6722",
"depart": "17:05",
"arrive": "01:30",
"price_usd": 289,
},
],
}
@tool
def get_stock_price(ticker: str) -> dict:
"""Get a mock current price for a stock ticker."""
return {
"ticker": ticker.upper(),
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
}
@tool
def roll_dice(sides: int = 6) -> dict:
"""Roll a single die with the given number of sides."""
return {"sides": sides, "result": randint(1, max(2, sides))}
SYSTEM_PROMPT = (
"You are a travel & lifestyle concierge. When a user asks a question, "
"reason step-by-step and call 2+ tools in succession when relevant."
)
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5-mini")
# No full CopilotKitMiddleware — this demo exercises only reasoning-token
# streaming alongside tool calls and doesn't consume frontend tools or app
# context. We attach the minimal HeaderForwardingMiddleware so the inbound
# ``x-aimock-context`` (and other ``x-*``) headers reach the outgoing
# /v1/responses call. Mirrors langgraph-python's tool_rendering_reasoning_chain.
graph = create_deep_agent(
model=init_chat_model(
f"openai:{REASONING_MODEL}",
use_responses_api=True,
reasoning={"effort": "low", "summary": "auto"},
),
tools=[get_weather, search_flights, get_stock_price, roll_dice],
system_prompt=SYSTEM_PROMPT,
middleware=[HeaderForwardingMiddleware()],
)
@@ -0,0 +1,32 @@
from langchain_core.messages import ToolCall
def should_route_to_tool_node(tool_calls: list[ToolCall], fe_tools: list[ToolCall]):
"""
Returns True if none of the tool calls are frontend tools.
Args:
tool_calls: List of tool calls from the model response
fe_tools: List of frontend tool names
Returns:
bool: True if all tool calls are backend tools, False if any are frontend tools
"""
if not tool_calls:
return False
# Get the set of frontend tool names for faster lookup
fe_tool_names = {tool.get("name") for tool in fe_tools}
# Check if any tool call is a frontend tool
for tool_call in tool_calls:
tool_name = (
tool_call.get("name")
if isinstance(tool_call, dict)
else getattr(tool_call, "name", None)
)
if tool_name in fe_tool_names:
return False
# None of the tool calls are frontend tools
return True
@@ -0,0 +1,53 @@
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its own
// endpoint (mirroring beautiful-chat) lets us set
// `a2ui.injectA2UITool: false` — the backend agent owns the `display_flight`
// tool which emits its own `a2ui_operations` container via `a2ui.render(...)`.
//
// Reference:
// - src/app/api/copilotkit/route.ts (LF main runtime)
// - src/agents/src/a2ui_fixed.py (the backend graph)
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
const a2uiFixedSchemaAgent = new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
graphId: "a2ui_fixed",
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
a2ui: {
// The backend emits its own `a2ui_operations` container via
// `a2ui.render(...)` inside `display_flight` (see src/agents/src/a2ui_fixed.py).
// We still run the A2UI middleware so it detects the container in tool
// results and forwards surfaces to the frontend — but we do NOT inject a
// runtime `render_a2ui` tool on top of the agent's existing tools.
injectA2UITool: false,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,57 @@
// Dedicated runtime for the A2UI Error Recovery demo.
// `a2ui.injectA2UITool: false` — the backend LangGraph agent OWNS
// `generate_a2ui` via `ag_ui_langgraph.get_a2ui_tools` (see
// src/agents/src/recovery_agent.py), whose body runs the `render_a2ui`
// sub-agent + the toolkit validate->retry recovery loop + the
// recovery-exhausted hard-fail envelope IN-GRAPH (OSS-158 / OSS-375). The
// runtime must NOT inject a second copy (double-bind); this `false` is
// load-bearing post CopilotKit#5611 (the provider catalog otherwise defaults
// injectA2UITool to true). The middleware still renders the building ->
// retrying (N/M) -> painted / failed lifecycle.
//
// The demo reuses the declarative-gen-ui catalog. The aimock fixtures force the
// inner render_a2ui sub-agent to emit free-form/sloppy args the middleware heals
// (heal pill) or a structurally-invalid surface on every attempt (exhaust pill).
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
const recoveryAgent = new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
graphId: "a2ui_recovery",
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "a2ui-recovery": recoveryAgent },
a2ui: {
injectA2UITool: false,
// Reuse the catalog the page registers (shared with declarative-gen-ui).
defaultCatalogId: "declarative-gen-ui-catalog",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-recovery",
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,92 @@
// Dedicated runtime for the Agent Config Object demo.
//
// This runtime hosts a single LangGraph agent (`agent_config_agent`).
// The Python graph reads three properties — tone / expertise / responseLength
// — from `RunnableConfig["configurable"]["properties"]` to build its system
// prompt dynamically per turn (see `src/agents/agent_config_agent.py`).
//
// ── Property-forwarding regression note ────────────────────────────
// Previously this route used a custom `AgentConfigLangGraphAgent` subclass
// that repacked the CopilotKit provider's `properties` into
// `forwardedProps.config.configurable.properties` so the Python graph could
// read them. That stopped working with `@ag-ui/langgraph@0.0.31`, which
// builds the LangGraph SDK request as
// `{ ..., config, context: { ...input.context, ...config.configurable } }`
// — i.e. it merges `configurable` INTO `context`. LangGraph 0.6.0+ rejects
// any request that sets both `configurable` and `context`:
//
// HTTP 400: "Cannot specify both configurable and context. Prefer setting
// context alone. Context was introduced in LangGraph 0.6.0 and is the long
// term planned replacement for configurable."
//
// Net effect: any forwardedProps that landed in `configurable.<key>` made
// the chat round-trip 400 unconditionally — the user message rendered, but
// no assistant reply ever came back.
//
// To unbreak the chat round-trip, this route now uses the plain
// `LangGraphAgent` and stops repacking properties into `configurable`. The
// Python graph falls back to its `DEFAULT_*` constants, so the demo's
// frontend toggles no longer affect the agent's response style. The
// property-forwarding feature is tracked as a known regression pending an
// `@ag-ui/langgraph` fix that decouples `context` from `configurable`.
//
// References:
// - src/agents/agent_config_agent.py — the graph (still reads
// configurable.properties; falls back to DEFAULT_* when missing)
// - src/app/demos/agent-config/page.tsx — the provider config
// - node_modules/.pnpm/@ag-ui+langgraph@0.0.31_*/dist/index.js — the
// prepareStream merge that introduces the conflict
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const LANGGRAPH_URL =
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123";
const agentConfigAgent = new LangGraphAgent({
deploymentUrl: LANGGRAPH_URL,
graphId: "agent_config_agent",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
const agents: Record<string, LangGraphAgent> = {
// The page's <CopilotKitProvider agent="agent-config-demo"> resolves here.
"agent-config-demo": agentConfigAgent,
// Internal components (headless-chat, example-canvas) call `useAgent()` with
// no args, which defaults to agentId "default". Alias to the same graph so
// those component hooks resolve instead of throwing "Agent 'default' not
// found".
default: agentConfigAgent,
};
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,
});
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,80 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook, which runs before routing and can short-circuit the
// request by throwing a Response. We validate a static `Authorization: Bearer
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
// agent.
//
// Implementation note: the V1 Next.js adapter
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
// framework-agnostic fetch handler that returns a plain
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
// App Router route export.
//
// References:
// - packages/runtime/src/v2/runtime/core/hooks.ts (onRequest semantics)
// - packages/runtime/src/v2/runtime/__tests__/hooks.test.ts (throw Response pattern)
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
// Reuse the neutral `sample_agent` graph for the authenticated path. The
// point of this demo is the gate mechanism, not per-user agent branching —
// authenticated users get the same behavior as any other neutral demo.
const authDemoAgent = new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
graphId: "sample_agent",
});
const runtime = new CopilotRuntime({
agents: {
"auth-demo": authDemoAgent,
// Fallback: useAgent() with no args resolves "default" — alias to the
// same agent so hooks in the demo page resolve cleanly.
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
// Framework-agnostic fetch handler with the auth gate wired up.
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
// Throwing a Response short-circuits the pipeline. The runtime maps
// thrown Responses to the HTTP response verbatim (status + body).
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" },
},
);
}
},
},
});
// Next.js App Router bindings. The handler is framework-agnostic — it takes
// a web Request and returns a web Response — so it drops straight into the
// POST/GET exports without any adapter shim.
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
@@ -0,0 +1,88 @@
// Dedicated runtime for the Beautiful Chat flagship showcase cell.
//
// Beautiful Chat simultaneously exercises A2UI (dynamic + fixed schema),
// Open Generative UI, and MCP Apps. The canonical reference
// (examples/integrations/langgraph-python) ships all three flags on a single
// runtime, but the 4085 showcase splits those concerns into per-feature
// endpoints so non-flagship cells keep their per-demo `useFrontendTool` /
// `useComponent` registrations isolated. This route restores the canonical's
// combined runtime for just the one cell that needs it.
//
// References:
// - examples/integrations/langgraph-python/src/app/api/copilotkit/[[...slug]]/route.ts
// - src/app/api/copilotkit-ogui/route.ts (scoping pattern)
// - src/app/api/copilotkit-mcp-apps/route.ts (mcpApps config pattern)
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const LANGGRAPH_URL =
process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123";
const beautifulChatAgent = new LangGraphAgent({
deploymentUrl: LANGGRAPH_URL,
graphId: "beautiful_chat",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
const agents: Record<string, LangGraphAgent> = {
// The page's <CopilotKit agent="beautiful-chat"> resolves here.
"beautiful-chat": beautifulChatAgent,
// Internal components (headless-chat, example-canvas) call `useAgent()`
// with no args, which defaults to agentId "default". Alias to the same
// graph so those component hooks resolve instead of throwing
// "Agent 'default' not found". This matches the canonical's
// `agents: { default: defaultAgent }` shape.
default: beautifulChatAgent,
};
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
// Canonical: openGenerativeUI: true, a2ui.injectA2UITool: false, mcpApps.
openGenerativeUI: true,
a2ui: {
// The backend graph has its own `generate_a2ui` tool, so we must NOT
// inject the runtime's default A2UI tool on top (that would double-bind
// the tool slot and confuse the LLM).
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",
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Stable serverId so persisted threads keep restoring the same MCP
// server across URL changes.
serverId: "beautiful_chat_mcp",
},
],
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-beautiful-chat",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,62 @@
// Dedicated runtime for the byoc-hashbrown demo (Wave 4a).
//
// The demo page (`src/app/demos/byoc-hashbrown/page.tsx`) wraps CopilotChat
// in the HashBrownDashboard provider and overrides the assistant message
// slot with a renderer that consumes hashbrown-shaped structured output via
// `@hashbrownai/react`'s `useUiKit` + `useJsonParser`. The agent behind this
// endpoint (`byoc_hashbrown_agent`) has a system prompt tuned to emit that
// shape — see `src/agents/byoc_hashbrown_agent.py`.
//
// Reference:
// - src/app/api/copilotkit-a2ui-fixed-schema/route.ts (topology this mirrors)
// - src/agents/byoc_hashbrown_agent.py (the backend graph)
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const LANGGRAPH_URL =
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123";
const byocHashbrownAgent = new LangGraphAgent({
deploymentUrl: LANGGRAPH_URL,
graphId: "byoc_hashbrown",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
const agents: Record<string, LangGraphAgent> = {
"byoc-hashbrown-demo": byocHashbrownAgent,
// Internal components (headless-chat, example-canvas) call `useAgent()` with
// no args, which defaults to agentId "default". Alias to the same graph so
// those component hooks resolve instead of throwing "Agent 'default' not
// found".
default: byocHashbrownAgent,
};
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
});
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,52 @@
/**
* Dedicated runtime for the BYOC json-render demo.
*
* Splitting into its own endpoint (mirroring beautiful-chat +
* declarative-gen-ui) keeps the `byoc_json_render` agent isolated from the
* default multi-agent `/api/copilotkit` runtime. The frontend's demo page
* (src/app/demos/byoc-json-render/page.tsx) points `<CopilotKit
* runtimeUrl>` here.
*/
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const LANGGRAPH_URL =
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123";
const byocJsonRenderAgent = new LangGraphAgent({
deploymentUrl: LANGGRAPH_URL,
graphId: "byoc_json_render",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
const runtime = new CopilotRuntime({
// @ts-ignore -- same-shape mismatch as the other dedicated routes in this
// package; the LangGraphAgent satisfies the runtime's agent interface at
// runtime but the generics don't line up across the v1/v2 boundary.
agents: { byoc_json_render: 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,42 @@
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema) cell.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
const declarativeGenUiAgent = new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
graphId: "a2ui_dynamic",
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "declarative-gen-ui": declarativeGenUiAgent },
// No runtime `a2ui` config: the page passes a catalog to the provider
// (`<CopilotKit a2ui={{ catalog }}>`), which auto-enables A2UI and defaults
// tool injection on (CopilotKit >= 1.61.2, PR #5611).
});
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,78 @@
// CopilotKit runtime for the MCP Apps cell — shared by two demos:
// - headless-complete (frontend wires runtimeUrl="/api/copilotkit-mcp-apps"
// and renders activity events via a hand-rolled useRenderActivityMessage
// in use-rendered-messages.tsx)
// - mcp-apps (frontend relies on CopilotKit's built-in MCPAppsActivityRenderer)
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
// agent: when the agent calls a tool backed by an MCP UI resource, the
// middleware fetches the resource and emits the activity event that the
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
// renders in the chat as a sandboxed iframe.
//
// 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 { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
const headlessCompleteAgent = new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
graphId: "headless_complete",
});
const mcpAppsAgent = new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
graphId: "mcp_apps",
});
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware to every registered agent: on each
// MCP tool call it fetches the associated UI resource and emits an
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
// inline in the chat.
const runtime = new CopilotRuntime({
// @ts-ignore
agents: {
"headless-complete": headlessCompleteAgent,
"mcp-apps": 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,61 @@
// Dedicated runtime for the Multimodal Attachments demo (Wave 2b).
//
// Why its own route? The backing graph (`multimodal`, from
// src/agents/multimodal_agent.py) runs a vision-capable model (gpt-4o). Every
// other cell in the showcase uses a text-only, cheaper model. Registering
// `multimodal` 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.
// A dedicated route keeps the vision capability — and its cost — scoped to
// exactly the cell that exercises it, matching the pattern used by
// `/api/copilotkit-beautiful-chat`.
//
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="multimodal-demo"` (the slug registered below).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
const multimodalAgent = new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
// graphId references the key in langgraph.json — must match the
// "multimodal" entry that resolves to src/agents/src/multimodal_agent.py:graph.
graphId: "multimodal",
});
const agents: Record<string, LangGraphAgent> = {
// The page's <CopilotKit agent="multimodal-demo"> resolves here.
"multimodal-demo": multimodalAgent,
// Alias for any internal component that calls `useAgent()` without args
// (matches the beautiful-chat route's "default" alias pattern).
default: multimodalAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts; published CopilotRuntime's `agents`
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
// plain Records. Fixed in source, pending release.
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
// Dedicated runtime for the Open Generative UI demos.
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
const LANGGRAPH_URL =
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123";
const openGenUiAgent = new LangGraphAgent({
deploymentUrl: LANGGRAPH_URL,
graphId: "open_gen_ui",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
const openGenUiAdvancedAgent = new LangGraphAgent({
deploymentUrl: LANGGRAPH_URL,
graphId: "open_gen_ui_advanced",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
});
const agents: Record<string, LangGraphAgent> = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
// Internal components (headless-chat, example-canvas) call `useAgent()` with
// no args, which defaults to agentId "default". Alias so those hooks resolve
// instead of throwing "Agent 'default' not found".
default: openGenUiAgent,
};
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: any) {
return NextResponse.json(
{ error: error.message, stack: error.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,117 @@
// Dedicated runtime for the /demos/voice cell.
//
// 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,
// instead of an opaque 5xx. The V2 runtime's `handleTranscribe` maps
// error messages containing "api key" or "unauthorized" to
// `AUTH_FAILED → HTTP 401`, so throwing with that message funnels the
// missing-key case into the intended 4xx path.
//
// Implementation
// --------------
// Wires the **V2** `CopilotRuntime` directly (from `@copilotkit/runtime/v2`)
// because the V1 wrapper in `@copilotkit/runtime` drops the
// `transcriptionService` option on the floor (see the TODO on the V1
// constructor). V2 URL-routes on `/info`, `/agent/:id/run`, `/transcribe`,
// etc., so the route file lives at `[[...slug]]/route.ts` to catch all
// sub-paths under `/api/copilotkit-voice`.
// @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 { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const LANGGRAPH_URL =
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123";
const voiceDemoAgent = new LangGraphAgent({
deploymentUrl: `${LANGGRAPH_URL}/`,
graphId: "sample_agent",
});
/**
* 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; any upstream Whisper error keeps its
* natural categorization.
*/
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) {
// "api key" substring → handleTranscribe maps to AUTH_FAILED → 401.
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. The guarded
// service reads OPENAI_API_KEY lazily in its transcribeFile call path, so
// deferring construction past module load is not required for cold-start
// safety under missing-key conditions.
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 neutral sample_agent graph.
"voice-demo": voiceDemoAgent,
// useAgent() with no args defaults to "default"; alias so any internal
// default-agent lookups resolve against the same graph.
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
// Next.js App Router bindings. This file lives at
// `src/app/api/copilotkit-voice/[[...slug]]/route.ts` — the catchall slug
// pattern forwards every sub-path (`/info`, `/agent/:id/run`,
// `/transcribe`, ...) to the V2 handler so its URL router can dispatch.
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,169 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import crypto from "node:crypto";
// Emit a structured server-side error log with a correlation id so the
// 500 we return to the client carries no stack/message details (which
// can leak internal config, prompts, or upstream URLs) while operators
// can still grep logs for the same `errorId` to find the full failure.
function logRouteError(err: unknown): string {
const error = err instanceof Error ? err : new Error(String(err));
const errorId = crypto.randomUUID();
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
phase: "setup",
errorId,
message: error.message,
stack: error.stack,
}),
);
return errorId;
}
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8123";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
// Per-request request/response logging is gated behind this flag (default off).
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
const ROUTE_DEBUG =
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
function createAgent(graphId: string = "sample_agent") {
return new LangGraphAgent({
deploymentUrl: `${AGENT_URL}/`,
graphId,
});
}
const agentNames = [
"agentic_chat",
"human_in_the_loop",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"chat-customization-css",
"headless-simple",
];
const agents: Record<string, LangGraphAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
// Dedicated-graph agents for tool-rendering + reasoning demos.
agents["tool-rendering"] = createAgent("tool_rendering");
agents["tool-rendering-default-catchall"] = createAgent("tool_rendering");
agents["tool-rendering-custom-catchall"] = createAgent("tool_rendering");
agents["tool-rendering-reasoning-chain"] = createAgent(
"tool_rendering_reasoning_chain",
);
agents["agentic-chat-reasoning"] = createAgent("reasoning_agent");
agents["reasoning-default-render"] = createAgent("reasoning_agent");
// Interrupt variants — share the dedicated `interrupt_agent` graph that uses
// langgraph's `interrupt()` primitive inside `schedule_meeting`.
agents["gen-ui-interrupt"] = createAgent("interrupt_agent");
agents["interrupt-headless"] = createAgent("interrupt_agent");
// Dedicated-graph agents — each cell has its own LangGraph graph with a
// tailored system prompt (tools=[], CopilotKitMiddleware attached).
agents["frontend_tools"] = createAgent("frontend_tools");
agents["frontend-tools-async"] = createAgent("frontend_tools_async");
agents["hitl-in-app"] = createAgent("hitl_in_app");
// In-Chat HITL via the high-level `useHumanInTheLoop` hook — backend
// agent has zero tools; the frontend-registered `book_call` tool is
// injected into the LLM's tool list by `CopilotKitMiddleware`. Both
// the canonical `hitl-in-chat` demo and the `hitl-in-chat-booking`
// alias share the same backend graph.
agents["hitl-in-chat"] = createAgent("hitl_in_chat");
agents["hitl-in-chat-booking"] = createAgent("hitl_in_chat");
// HITL step-selection: dedicated graph with tools=[] + CopilotKitMiddleware.
// The `human_in_the_loop` alias in the neutral-assistant loop above maps to
// `sample_agent` which has 7+ backend tools and a custom AgentState — the
// frontend-only `generate_task_steps` tool (from useHumanInTheLoop) is the
// ONLY tool this demo needs, so a minimal graph avoids state/tool contention.
agents["human_in_the_loop"] = createAgent("hitl_steps");
agents["readonly-state-agent-context"] = createAgent(
"readonly_state_agent_context",
);
// Shared State (Read + Write) — bidirectional shared state between UI and
// agent. UI writes `preferences` via agent.setState; middleware reads them
// into the system prompt; agent writes `notes` back via the `set_notes` tool.
agents["shared-state-read-write"] = createAgent("shared_state_read_write");
// Sub-Agents — supervisor delegates to research_agent / writing_agent /
// critique_agent (each a full create_agent under the hood). Every delegation
// is appended to `state.delegations` for live UI rendering.
agents["subagents"] = createAgent("subagents");
agents["default"] = createAgent();
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore
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 message + stack server-side under a correlation id; return
// only the id to the client so we don't leak internal details (upstream
// URLs, env-driven config, prompts, etc.) into HTTP responses.
const errorId = logRouteError(error);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
export const GET = async () => {
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/ok`, {
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,
});
};
@@ -0,0 +1,49 @@
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 || process.env.LANGGRAPH_DEPLOYMENT_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: "langgraph-fastapi",
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: "langgraph-fastapi",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "langgraph-fastapi";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
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,13 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useA2uiRecoverySuggestions } from "./suggestions";
// Note: this integration's declarative-gen-ui demo does not ship a
// sales-context hook (unlike langgraph-python / strands), so the recovery demo
// does not inject one either. The agent's system prompt + the render planner's
// composition guide carry the dataset; the aimock fixtures drive heal/exhaust.
export function Chat() {
useA2uiRecoverySuggestions();
return <CopilotChat agentId="a2ui-recovery" className="h-full rounded-2xl" />;
}
@@ -0,0 +1,43 @@
"use client";
/**
* A2UI Error Recovery demo.
*
* Same dynamic-schema A2UI setup as declarative-gen-ui (it reuses that demo's
* catalog), but it makes the toolkit's validate->retry recovery loop visible.
* The dedicated runtime at `/api/copilotkit-a2ui-recovery` is configured with
* `injectA2UITool: false` — the backend agent
* (`src/agents/src/recovery_agent.py`) owns `generate_a2ui` via
* `ag_ui_langgraph.get_a2ui_tools`, whose body runs the forced `render_a2ui`
* sub-agent and the recovery loop + recovery-exhausted hard-fail envelope
* IN-GRAPH (OSS-158 / OSS-375).
*
* The two suggestion pills drive aimock fixtures that force:
* - HEAL: an invalid first render that recovers to a valid one
* (building -> retrying -> painted).
* - EXHAUST: an always-invalid render that hits the attempt cap
* (a tasteful `failed` state, never a broken surface).
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
// Reuse the declarative-gen-ui catalog (same components, same catalogId).
import { myCatalog } from "../declarative-gen-ui/a2ui/catalog";
import { Chat } from "./chat";
export default function A2uiRecoveryDemo() {
return (
<CopilotKit
runtimeUrl="/api/copilotkit-a2ui-recovery"
agent="a2ui-recovery"
a2ui={{ catalog: myCatalog }}
>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,28 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
// Two pills exercise the recovery loop deterministically via aimock fixtures
// (showcase/aimock/d6/langgraph-fastapi/a2ui-recovery.json). Prompts are unique
// within the langgraph-fastapi context so they don't collide with the
// declarative-gen-ui (a2ui_dynamic) fixtures.
// - "heal": inner render_a2ui returns free-form/sloppy args (components &
// data as JSON strings) -> middleware parse_and_fix heals them
// into a valid surface in a single pass -> painted.
// - "exhaust": inner render_a2ui is invalid on every attempt -> attempt cap
// hit -> a2ui_recovery_exhausted -> tasteful `failed` state.
export function useA2uiRecoverySuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Recover a bad render",
message:
"Put together a quarterly metrics overview and repair a malformed first attempt.",
},
{
title: "Show an unrecoverable failure",
message:
"Put together an overview whose every render is invalid so I can see the fallback.",
},
],
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,144 @@
"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 } from "@copilotkit/react-core/v2";
import 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 />}
/>
);
}

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