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,7 @@
cdk.out*/
infra-cdk/node_modules/
infra-cdk/cdk.out*/
frontend/node_modules/
**/node_modules/
**/__pycache__/
**/*.pyc
@@ -0,0 +1,35 @@
# Python
__pycache__/
*.py[cod]
.venv/
*.egg-info/
.uv/
# Node
node_modules/
dist/
.vite/
build/
# CDK
cdk.out*/
infra-cdk/cdk.out*/
# AWS
aws-exports.json
frontend/public/aws-exports.json
*.zip
# Local config (contains personal email/stack name — use config.yaml.example as template)
config.yaml
# Local dev credentials and generated env
docker/.env
# docker/.env holds creds + stack values — never commit it
# Terraform
infra-terraform/.terraform/
infra-terraform/*.tfstate
infra-terraform/*.tfstate.backup
infra-terraform/.terraform.lock.hcl
infra-terraform/terraform.tfvars
+92
View File
@@ -0,0 +1,92 @@
# CopilotKit + AWS AgentCore
Chat UI with generative charts, shared-state todo canvas, and inline tool rendering — deployed on AWS Bedrock AgentCore. Pick LangGraph or Strands.
## Prerequisites
| Tool | Version |
| ------- | ---------------------------- |
| AWS CLI | configured (`aws configure`) |
| Node.js | 18+ |
| Python | 3.8+ |
| Docker | running |
## Deploy to AWS
1. **Create your config:**
```bash
cp config.yaml.example config.yaml
# Edit config.yaml — set stack_name_base and admin_user_email
```
2. **Deploy:**
```bash
./deploy-langgraph.sh # LangGraph agent (infra + frontend)
./deploy-langgraph.sh --skip-frontend # infra/agent only
./deploy-langgraph.sh --skip-backend # frontend only
# or
./deploy-strands.sh # AWS Strands agent
./deploy-strands.sh --skip-frontend
./deploy-strands.sh --skip-backend
```
3. **Open** the Amplify URL printed at the end. Sign in with your email.
## Local Development
```bash
cd docker
cp .env.example .env
# Fill in AWS creds — STACK_NAME, MEMORY_ID, and aws-exports.json are auto-resolved
./up.sh --build
```
- **Frontend** → hot reloads on save (volume mount + Vite)
- **Agent** → rebuild on changes: `docker compose up --build agent`
- **Browser** → `http://localhost:3000`, auth redirects back to localhost
The full chain runs locally: `browser:3000 → bridge:3001 → agent:8080`. AWS is only used for Memory and Gateway (SSM/OAuth2).
See `docs/LOCAL_DEVELOPMENT.md` for full details.
## What's inside
| Piece | What it does |
| -------------------------------- | ---------------------------------------------------------- |
| `frontend/` | Vite + React with CopilotKit chat, charts, todo canvas |
| `agents/langgraph-single-agent/` | LangGraph agent with tools + shared todo state |
| `agents/strands-single-agent/` | Strands agent with tools + shared todo state |
| `infra-cdk/` | CDK: Cognito, AgentCore, CopilotKit Lambda bridge, Amplify |
| `infra-terraform/` | Terraform equivalent — see `infra-terraform/README.md` |
| `docker/` | Local dev via Docker Compose |
| `docs/` | LOCAL_DEVELOPMENT.md, LOCAL_DOCKER_TESTING.md |
## Architecture
```
Browser → API Gateway → CopilotKit Lambda (Node.js, AG-UI bridge)
AgentCore Runtime
langgraph_agent.py / strands_agent.py
↓ MCP (OAuth2 M2M)
AgentCore Gateway → Lambda tools
```
Auth: Cognito OIDC → Bearer token forwarded from browser through Lambda to AgentCore.
## Tear down
```bash
cd infra-cdk && npx cdk@latest destroy --all --output ../cdk.out-lg # LangGraph stack
cd infra-cdk && npx cdk@latest destroy --all --output ../cdk.out-st # Strands stack
```
## Docs
- [CopilotKit](https://docs.copilotkit.ai)
- [AWS Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/)
- [Local Development](docs/LOCAL_DEVELOPMENT.md)
- [Local Docker Testing](docs/LOCAL_DOCKER_TESTING.md)
@@ -0,0 +1,36 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app
# Configure UV for container environment
ENV UV_SYSTEM_PYTHON=1 \
UV_COMPILE_BYTECODE=1 \
DOCKER_CONTAINER=1 \
OTEL_PYTHON_LOG_CORRELATION=true \
PYTHONUNBUFFERED=1
# Copy and install agent-specific requirements first
COPY agents/langgraph-single-agent/requirements.txt requirements.txt
RUN uv pip install --no-cache -r requirements.txt && \
uv pip install --no-cache aws-opentelemetry-distro==0.16.0
# Create non-root user
RUN useradd -m -u 1000 bedrock_agentcore
USER bedrock_agentcore
EXPOSE 8080
# Copy agent code, tools, and shared utilities
COPY agents/langgraph-single-agent/langgraph_agent.py .
COPY agents/langgraph-single-agent/tools/ tools/
COPY agents/utils/ utils/
# Healthcheck using Python (no extra dependencies needed)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/ping', timeout=2)" || exit 1
# Start agent with OpenTelemetry instrumentation
CMD ["opentelemetry-instrument", "python", "-m", "langgraph_agent"]
@@ -0,0 +1,159 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
import os
from ag_ui.core import RunAgentInput, RunErrorEvent
from bedrock_agentcore.identity.auth import requires_access_token
from bedrock_agentcore.runtime import BedrockAgentCoreApp, RequestContext
from copilotkit import (
CopilotKitMiddleware,
LangGraphAGUIAgent,
StateStreamingMiddleware,
StateItem,
)
from langchain.agents import create_agent
from langchain_aws import ChatBedrock
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph_checkpoint_aws import AgentCoreMemorySaver
from utils.auth import extract_user_id_from_context
from utils.ssm import get_ssm_parameter
from tools import query_data, AgentState, todo_tools
app = BedrockAgentCoreApp()
ACTOR_ID_KEYS = ("actor_id", "actorId", "user_id", "userId", "sub")
SYSTEM_PROMPT = """You are a helpful assistant with access to tools via the Gateway and built-in data tools.
When demonstrating charts, always call the query_data tool first to fetch data from the database before calling any chart tool.
When managing todos, use manage_todos to update the list and get_todos to read the current list.
When asked about your tools, list them and explain what they do."""
@requires_access_token(
provider_name=os.environ["GATEWAY_CREDENTIAL_PROVIDER_NAME"],
auth_flow="M2M",
scopes=[],
)
async def _fetch_gateway_token(access_token: str) -> str:
return access_token
async def create_gateway_mcp_client() -> MultiServerMCPClient:
stack_name = os.environ.get("STACK_NAME")
if not stack_name:
raise ValueError("STACK_NAME environment variable is required")
if not stack_name.replace("-", "").replace("_", "").isalnum():
raise ValueError("Invalid STACK_NAME format")
gateway_url = get_ssm_parameter(f"/{stack_name}/gateway_url")
fresh_token = await _fetch_gateway_token()
return MultiServerMCPClient(
{
"gateway": {
"transport": "streamable_http",
"url": gateway_url,
"headers": {
"Authorization": f"Bearer {fresh_token}",
},
}
}
)
def _build_model(streaming: bool) -> ChatBedrock:
return ChatBedrock(
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
temperature=0.1,
max_tokens=16384,
streaming=streaming,
beta_use_converse_api=True,
)
def _build_checkpointer() -> AgentCoreMemorySaver:
memory_id = os.environ.get("MEMORY_ID")
if not memory_id:
raise ValueError("MEMORY_ID environment variable is required")
return AgentCoreMemorySaver(
memory_id=memory_id,
region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
)
@app.entrypoint
async def invocations(payload: dict, context: RequestContext):
input_data = RunAgentInput.model_validate(payload)
# Extract actor identity securely from the validated JWT token.
try:
actor_id = extract_user_id_from_context(context)
except ValueError:
# Fall back to forwarded props if JWT extraction fails (e.g. local dev).
forwarded = (
input_data.forwarded_props
if isinstance(input_data.forwarded_props, dict)
else {}
)
actor_id = next(
(forwarded[k] for k in ACTOR_ID_KEYS if k in forwarded and forwarded[k]),
None,
)
if not actor_id:
raise ValueError(
"Missing actor identity. Provide forwardedProps.actor_id/user_id "
"or include sub claim in the bearer token."
)
try:
try:
mcp_client = await create_gateway_mcp_client()
gateway_tools = await mcp_client.get_tools()
except Exception as gw_err:
logging.warning("Gateway tools unavailable (running locally?): %s", gw_err)
gateway_tools = []
graph = create_agent(
model=_build_model(streaming=True),
tools=[*gateway_tools, query_data, *todo_tools],
checkpointer=_build_checkpointer(),
middleware=[
CopilotKitMiddleware(),
StateStreamingMiddleware(
StateItem(
state_key="todos", tool="manage_todos", tool_argument="todos"
)
),
],
system_prompt=SYSTEM_PROMPT,
state_schema=AgentState,
)
agent = LangGraphAGUIAgent(
name="LangGraphSingleAgent",
description="LangGraph single agent exposed via AG-UI",
graph=graph,
config={"configurable": {"actor_id": actor_id}},
)
async for event in agent.run(input_data):
if event is not None:
yield event.model_dump(mode="json", by_alias=True, exclude_none=True)
except Exception as exc:
logging.exception("Agent run failed")
yield RunErrorEvent(
message=str(exc) or type(exc).__name__,
code=type(exc).__name__,
).model_dump(mode="json", by_alias=True, exclude_none=True)
if __name__ == "__main__":
app.run()
@@ -0,0 +1,14 @@
# LangGraph agent dependencies with pinned versions
fastapi==0.115.12
uvicorn==0.34.2
ag-ui-protocol>=0.1.15
ag-ui-langgraph==0.0.33
copilotkit==0.1.87
partialjson>=0.0.8
langgraph==1.0.10rc1
langchain>=0.3.0
langchain-aws==1.0.0
langchain-mcp-adapters==0.1.13
langgraph-checkpoint-aws==1.0.5
mcp==1.23.1
bedrock-agentcore==1.0.6
@@ -0,0 +1,8 @@
# patterns/langgraph-single-agent/tools/__init__.py
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
from .query_data import query_data
from .todos import AgentState, todo_tools
__all__ = ["query_data", "AgentState", "todo_tools"]
@@ -0,0 +1,16 @@
date,category,amount,type
2026-01-05,Food,42.50,expense
2026-01-10,Transport,15.00,expense
2026-01-15,Salary,3500.00,income
2026-01-20,Entertainment,80.00,expense
2026-01-25,Utilities,120.00,expense
2026-02-03,Food,55.20,expense
2026-02-08,Freelance,800.00,income
2026-02-14,Dining,65.00,expense
2026-02-20,Transport,22.50,expense
2026-02-28,Salary,3500.00,income
2026-03-05,Groceries,95.40,expense
2026-03-10,Gym,40.00,expense
2026-03-15,Salary,3500.00,income
2026-03-18,Coffee,18.75,expense
2026-03-22,Books,35.00,expense
1 date category amount type
2 2026-01-05 Food 42.50 expense
3 2026-01-10 Transport 15.00 expense
4 2026-01-15 Salary 3500.00 income
5 2026-01-20 Entertainment 80.00 expense
6 2026-01-25 Utilities 120.00 expense
7 2026-02-03 Food 55.20 expense
8 2026-02-08 Freelance 800.00 income
9 2026-02-14 Dining 65.00 expense
10 2026-02-20 Transport 22.50 expense
11 2026-02-28 Salary 3500.00 income
12 2026-03-05 Groceries 95.40 expense
13 2026-03-10 Gym 40.00 expense
14 2026-03-15 Salary 3500.00 income
15 2026-03-18 Coffee 18.75 expense
16 2026-03-22 Books 35.00 expense
@@ -0,0 +1,25 @@
# patterns/langgraph-single-agent/tools/query_data.py
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import csv
from pathlib import Path
from langchain.tools import tool
# Read at module load time — avoids file I/O on every tool invocation.
_csv_path = Path(__file__).parent / "db.csv"
try:
with open(_csv_path) as _f:
_cached_data = list(csv.DictReader(_f))
except (FileNotFoundError, OSError) as e:
raise RuntimeError(f"query_data: cannot load sample data from {_csv_path}") from e
@tool
def query_data(query: str) -> list[dict]:
"""
Query the database. Accepts natural language.
Always call this tool before displaying a chart or graph.
"""
return _cached_data
@@ -0,0 +1,66 @@
# patterns/langgraph-single-agent/tools/todos.py
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import uuid
from typing import Literal, TypedDict
from langchain.agents import AgentState as BaseAgentState
from langchain.tools import ToolRuntime, tool
from langchain_core.messages import ToolMessage
from langgraph.types import Command
# ToolRuntime is confirmed available at langchain.tools (langchain >= 1.2).
# If you see an ImportError, verify your langchain version is >= 0.3.
class Todo(TypedDict):
id: str
title: str
description: str
emoji: str
status: Literal["pending", "completed"]
class AgentState(BaseAgentState):
todos: list[Todo]
def _assign_ids(todos: list[dict]) -> list[dict]:
"""Assign a uuid4 to any todo that has a missing or empty 'id'."""
for todo in todos:
if not todo.get("id"):
todo["id"] = str(uuid.uuid4())
return todos
@tool
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
"""
Manage the current todos. Replaces the entire todo list.
Assigns a unique UUID to any todo that is missing one.
"""
_assign_ids(todos) # type: ignore[arg-type]
return Command(
update={
"todos": todos,
"messages": [
ToolMessage(
content="Successfully updated todos",
tool_call_id=runtime.tool_call_id,
)
],
}
)
@tool
def get_todos(runtime: ToolRuntime) -> list[Todo]:
"""
Get the current todo list from agent state.
"""
return runtime.state.get("todos", [])
todo_tools = [manage_todos, get_todos]
@@ -0,0 +1,36 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app
# Configure UV for container environment
ENV UV_SYSTEM_PYTHON=1 \
UV_COMPILE_BYTECODE=1 \
DOCKER_CONTAINER=1 \
OTEL_PYTHON_LOG_CORRELATION=true \
PYTHONUNBUFFERED=1
# Copy and install agent-specific requirements first
COPY agents/strands-single-agent/requirements.txt requirements.txt
RUN uv pip install --no-cache -r requirements.txt && \
uv pip install --no-cache aws-opentelemetry-distro==0.16.0
# Create non-root user
RUN useradd -m -u 1000 bedrock_agentcore
USER bedrock_agentcore
EXPOSE 8080
# Copy agent code and shared utilities
COPY agents/strands-single-agent/strands_agent.py .
COPY agents/strands-single-agent/tools/ tools/
COPY agents/utils/ utils/
# Healthcheck using Python (no extra dependencies needed)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/ping', timeout=2)" || exit 1
# Start agent with OpenTelemetry instrumentation
CMD ["opentelemetry-instrument", "python", "-m", "strands_agent"]
@@ -0,0 +1,8 @@
# Strands agent dependencies with pinned versions
strands-agents==1.24.0
mcp==1.26.0
bedrock-agentcore[strands-agents]==1.2.0
PyJWT[crypto]>=2.10.1
ag-ui-protocol>=0.1.10
ag-ui-strands==0.1.2
fastapi>=0.115.12
@@ -0,0 +1,229 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import logging
import os
import traceback
from ag_ui.core import RunAgentInput, RunErrorEvent
from ag_ui_strands import (
StrandsAgent,
StrandsAgentConfig,
ToolBehavior,
PredictStateMapping,
)
from ag_ui_strands.config import ToolCallContext
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
from bedrock_agentcore.memory.integrations.strands.session_manager import (
AgentCoreMemorySessionManager,
)
from bedrock_agentcore.runtime import BedrockAgentCoreApp, RequestContext
from mcp.client.streamable_http import streamablehttp_client
from strands import Agent
from strands.models import BedrockModel
from strands.tools.mcp import MCPClient
from tools.query_data import query_data
from tools.todos import manage_todos
from utils.auth import extract_user_id_from_context, get_gateway_access_token
from utils.ssm import get_ssm_parameter
app = BedrockAgentCoreApp()
logger = logging.getLogger(__name__)
ACTOR_ID_KEYS = ("actor_id", "actorId", "user_id", "userId", "sub")
SYSTEM_PROMPT = """You are a helpful assistant with access to tools via the Gateway and built-in data tools.
When demonstrating charts, always call the query_data tool first to fetch data from the database before calling any chart tool.
When managing todos, use manage_todos to update the list.
When asked about your tools, list them and explain what they do."""
BEDROCK_MODEL = BedrockModel(
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
temperature=0.1,
)
def create_gateway_mcp_client() -> MCPClient:
"""
Create MCP client for AgentCore Gateway with OAuth2 authentication.
Calls get_gateway_access_token() inside the lambda factory to ensure a fresh
token is fetched on every MCP reconnection (avoids the closure trap).
"""
stack_name = os.environ.get("STACK_NAME")
if not stack_name:
raise ValueError("STACK_NAME environment variable is required")
if not stack_name.replace("-", "").replace("_", "").isalnum():
raise ValueError("Invalid STACK_NAME format")
gateway_url = get_ssm_parameter(f"/{stack_name}/gateway_url")
return MCPClient(
lambda: streamablehttp_client(
url=gateway_url,
headers={"Authorization": f"Bearer {get_gateway_access_token()}"},
),
prefix="gateway",
)
def create_strands_agent(actor_id: str, session_id: str) -> StrandsAgent:
"""
Create a StrandsAgent wrapping a Strands SDK agent with AgentCore memory,
Gateway MCP tools, and CopilotKit-compatible AG-UI configuration.
Memory: AgentCoreMemorySessionManager provides cloud-persistent conversation
history keyed by actor_id, matching the AgentCoreMemorySaver approach used
in the LangGraph pattern.
"""
memory_id = os.environ.get("MEMORY_ID")
if not memory_id:
raise ValueError("MEMORY_ID environment variable is required")
agentcore_memory_config = AgentCoreMemoryConfig(
memory_id=memory_id, session_id=session_id, actor_id=actor_id
)
session_manager = AgentCoreMemorySessionManager(
agentcore_memory_config=agentcore_memory_config,
region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
)
gateway_client = create_gateway_mcp_client()
# Inject current todos into the system prompt so the agent always knows
# the latest todo state without needing a separate get_todos tool.
def state_context_builder(state: dict) -> str:
todos = state.get("todos", [])
if todos:
return f"\nCurrent todos:\n{json.dumps(todos, indent=2)}"
return ""
# When manage_todos is called, emit a StateSnapshotEvent with the new todos
# so the frontend updates immediately (before the tool result arrives).
async def todos_state_from_args(ctx: ToolCallContext) -> dict:
todos = (ctx.tool_input or {}).get("todos", [])
return {"todos": todos}
# Frontend tools (generative UI / canvas controls): let the agent continue after
# calling them so it generates a proper conclusion text. The run then finishes
# naturally and ag_ui_strands sends a MessagesSnapshotEvent that preserves the
# chat history. Without continue_after_frontend_call the stream halts and
# CopilotKit v2 clears the UI because no snapshot was sent.
frontend_tool_behavior = ToolBehavior(
continue_after_frontend_call=False,
skip_messages_snapshot=False,
)
config = StrandsAgentConfig(
tool_behaviors={
"manage_todos": ToolBehavior(
state_from_args=todos_state_from_args,
predict_state=[
PredictStateMapping(
state_key="todos",
tool="manage_todos",
tool_argument="todos",
)
],
),
"pieChart": frontend_tool_behavior,
"barChart": frontend_tool_behavior,
"toggleTheme": frontend_tool_behavior,
"scheduleTime": frontend_tool_behavior,
"enableAppMode": frontend_tool_behavior,
"enableChatMode": frontend_tool_behavior,
},
state_context_builder=state_context_builder,
)
# Build the underlying Strands agent with persistent memory and tools.
core_agent = Agent(
name="FASTAgent",
system_prompt=SYSTEM_PROMPT,
tools=[gateway_client, query_data, manage_todos],
model=BEDROCK_MODEL,
session_manager=session_manager,
record_direct_tool_call=True,
trace_attributes={
"user.id": actor_id,
"session.id": session_id,
},
)
strands_agent = StrandsAgent(
agent=core_agent,
name="FASTAgent",
description="FAST Strands agent with CopilotKit generative UI support",
config=config,
)
# Pre-seed the per-thread agent cache so StrandsAgent.run() uses our
# core_agent (which has AgentCoreMemorySessionManager) rather than creating
# a new instance without it.
strands_agent._agents_by_thread[session_id] = core_agent
return strands_agent
@app.entrypoint
async def invocations(payload: dict, context: RequestContext):
"""
Main entrypoint for the Strands agent using AG-UI protocol.
Accepts RunAgentInput payloads from the CopilotKit Lambda Runtime,
streams AG-UI events back, and supports generative UI, shared state
(todos), and human-in-the-loop interactions via CopilotKit.
"""
input_data = RunAgentInput.model_validate(payload)
# Extract actor identity securely from the validated JWT token.
try:
actor_id = extract_user_id_from_context(context)
except ValueError:
# Fall back to forwarded props if JWT extraction fails (e.g. local dev).
forwarded = (
input_data.forwarded_props
if isinstance(input_data.forwarded_props, dict)
else {}
)
actor_id = next(
(forwarded[k] for k in ACTOR_ID_KEYS if k in forwarded and forwarded[k]),
None,
)
if not actor_id:
raise ValueError(
"Missing actor identity. Provide forwardedProps.actor_id/user_id "
"or include sub claim in the bearer token."
)
# Use thread_id from the request (set by CopilotKit runtime) or fall back
# to actor_id so each user gets their own persistent conversation thread.
session_id = input_data.thread_id or actor_id
# Ensure thread_id in the payload matches so StrandsAgent uses our pre-seeded agent.
input_data = input_data.model_copy(update={"thread_id": session_id})
try:
strands_agent = create_strands_agent(actor_id, session_id)
async for event in strands_agent.run(input_data):
if event is not None:
yield event.model_dump(mode="json", by_alias=True, exclude_none=True)
except Exception as exc:
logger.exception("Agent run failed")
yield RunErrorEvent(
message=str(exc) or type(exc).__name__,
code=type(exc).__name__,
).model_dump(mode="json", by_alias=True, exclude_none=True)
if __name__ == "__main__":
app.run()
@@ -0,0 +1,4 @@
from .query_data import query_data
from .todos import manage_todos, Todo
__all__ = ["query_data", "manage_todos", "Todo"]
@@ -0,0 +1,16 @@
date,category,amount,type
2026-01-05,Food,42.50,expense
2026-01-10,Transport,15.00,expense
2026-01-15,Salary,3500.00,income
2026-01-20,Entertainment,80.00,expense
2026-01-25,Utilities,120.00,expense
2026-02-03,Food,55.20,expense
2026-02-08,Freelance,800.00,income
2026-02-14,Dining,65.00,expense
2026-02-20,Transport,22.50,expense
2026-02-28,Salary,3500.00,income
2026-03-05,Groceries,95.40,expense
2026-03-10,Gym,40.00,expense
2026-03-15,Salary,3500.00,income
2026-03-18,Coffee,18.75,expense
2026-03-22,Books,35.00,expense
1 date category amount type
2 2026-01-05 Food 42.50 expense
3 2026-01-10 Transport 15.00 expense
4 2026-01-15 Salary 3500.00 income
5 2026-01-20 Entertainment 80.00 expense
6 2026-01-25 Utilities 120.00 expense
7 2026-02-03 Food 55.20 expense
8 2026-02-08 Freelance 800.00 income
9 2026-02-14 Dining 65.00 expense
10 2026-02-20 Transport 22.50 expense
11 2026-02-28 Salary 3500.00 income
12 2026-03-05 Groceries 95.40 expense
13 2026-03-10 Gym 40.00 expense
14 2026-03-15 Salary 3500.00 income
15 2026-03-18 Coffee 18.75 expense
16 2026-03-22 Books 35.00 expense
@@ -0,0 +1,21 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import csv
import os
from strands import tool
@tool
def query_data(query: str) -> str:
"""
Query financial data from the database. Use this tool to fetch data before
rendering any charts. Returns CSV-formatted data relevant to the query.
"""
db_path = os.path.join(os.path.dirname(__file__), "db.csv")
try:
with open(db_path, "r") as f:
content = f.read()
return content
except FileNotFoundError:
return "No data available."
@@ -0,0 +1,23 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
from typing import Literal, TypedDict
from strands import tool
class Todo(TypedDict):
id: str
title: str
description: str
emoji: str
status: Literal["pending", "completed"]
@tool
def manage_todos(todos: list) -> str:
"""
Manage the current todos. Replaces the entire todo list.
Each todo should have: id (str), title (str), description (str), emoji (str), status ('pending' or 'completed').
"""
return "Todos updated successfully"
@@ -0,0 +1,100 @@
"""
Authentication utilities for agent patterns.
Provides secure user identity extraction from JWT tokens in the AgentCore Runtime
RequestContext (prevents impersonation via prompt injection).
"""
import logging
import os
import jwt
from bedrock_agentcore.identity.auth import requires_access_token
from bedrock_agentcore.runtime import RequestContext
logger = logging.getLogger(__name__)
def extract_user_id_from_context(context: RequestContext) -> str:
"""
Securely extract the user ID from the JWT token in the request context.
AgentCore Runtime validates the JWT token before passing it to the agent,
so we can safely skip signature verification here. The user ID is taken
from the token's 'sub' claim rather than from the request payload, which
prevents impersonation via prompt injection.
Args:
context (RequestContext): The request context provided by AgentCore
Runtime, containing validated request headers including the
Authorization JWT.
Returns:
str: The user ID (sub claim) extracted from the validated JWT token.
Raises:
ValueError: If the Authorization header is missing or the JWT does
not contain a 'sub' claim.
"""
request_headers = context.request_headers
if not request_headers:
raise ValueError(
"No request headers found in context. "
"Ensure the AgentCore Runtime is configured with a request header allowlist "
"that includes the Authorization header."
)
auth_header = request_headers.get("Authorization")
if not auth_header:
raise ValueError(
"No Authorization header found in request context. "
"Ensure the AgentCore Runtime is configured with JWT inbound auth "
"and the Authorization header is in the request header allowlist."
)
# Remove "Bearer " prefix to get the raw JWT token
token = (
auth_header.replace("Bearer ", "")
if auth_header.startswith("Bearer ")
else auth_header
)
# Decode without signature verification — AgentCore Runtime already validated the token.
# We use options to skip all verification since this is a trusted, pre-validated token.
claims = jwt.decode(
jwt=token,
options={"verify_signature": False},
algorithms=["RS256"],
)
user_id = claims.get("sub")
if not user_id:
raise ValueError(
"JWT token does not contain a 'sub' claim. Cannot determine user identity."
)
logger.info("Extracted user_id from JWT: %s", user_id)
return user_id
@requires_access_token(
provider_name=os.environ.get("GATEWAY_CREDENTIAL_PROVIDER_NAME", ""),
auth_flow="M2M",
scopes=[],
)
def get_gateway_access_token(access_token: str) -> str:
"""
Fetch OAuth2 access token for AgentCore Gateway authentication.
The @requires_access_token decorator handles token retrieval and refresh:
1. Token Retrieval: Calls GetResourceOauth2Token API to fetch token from Token Vault
2. Automatic Refresh: Uses refresh tokens to renew expired access tokens
3. Error Orchestration: Handles missing tokens and OAuth flow management
For M2M (Machine-to-Machine) flows, the decorator uses Client Credentials grant type.
The provider_name must match the Name field in the CDK OAuth2CredentialProvider resource.
This is synchronous because it's called during agent setup before the async
message processing loop.
"""
return access_token
@@ -0,0 +1,45 @@
"""
SSM Parameter Store utilities for agent patterns.
Provides a single shared function for fetching parameters from AWS SSM
Parameter Store, used by agents to retrieve configuration values like
Gateway URLs that are set during deployment.
"""
import logging
import os
import boto3
logger = logging.getLogger(__name__)
def get_ssm_parameter(parameter_name: str) -> str:
"""
Fetch a parameter value from AWS SSM Parameter Store.
SSM Parameter Store is AWS's service for storing configuration values
securely. This function retrieves values like Gateway URLs and other
stack-specific configuration that are set during CDK deployment.
Args:
parameter_name (str): The full SSM parameter name/path
(e.g. '/my-stack/gateway_url').
Returns:
str: The parameter value.
Raises:
ValueError: If the parameter is not found or cannot be retrieved.
"""
region = os.environ.get(
"AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
)
ssm = boto3.client("ssm", region_name=region)
try:
response = ssm.get_parameter(Name=parameter_name)
return response["Parameter"]["Value"]
except ssm.exceptions.ParameterNotFound:
raise ValueError(f"SSM parameter not found: {parameter_name}")
except Exception as e:
raise ValueError(f"Failed to retrieve SSM parameter {parameter_name}: {e}")
@@ -0,0 +1,9 @@
# ── User-editable settings ──────────────────────────────────────────────────
stack_name_base: my-copilotkit-agentcore-lg # max 35 chars; used as prefix for all AWS resources
admin_user_email: # e.g. you@example.com — auto-creates a Cognito user
backend:
# Set automatically by deploy scripts — do not edit.
pattern: langgraph-single-agent # overwritten by deploy-langgraph.sh / deploy-strands.sh
deployment_type: docker # docker (default) or zip
network_mode: PUBLIC # PUBLIC (default) or VPC
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# deploy-langgraph.sh — Deploy CopilotKit + LangGraph on AWS AgentCore
# Usage: ./deploy-langgraph.sh [--skip-frontend] [--skip-backend]
# Stack: <stack_name_base>-lg (isolated from deploy-strands.sh)
# Using Terraform instead? See infra-terraform/README.md
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PATTERN="langgraph-single-agent"
SUFFIX="-lg"
CONFIG="$SCRIPT_DIR/config.yaml"
CDK_DIR="$SCRIPT_DIR/infra-cdk"
SKIP_FRONTEND=false
SKIP_BACKEND=false
for arg in "$@"; do
[[ "$arg" == "--skip-frontend" ]] && SKIP_FRONTEND=true
[[ "$arg" == "--skip-backend" ]] && SKIP_BACKEND=true
done
echo "── CopilotKit + AWS AgentCore (LangGraph) ──────────────────────────────"
# ── Preflight checks ──────────────────────────────────────────────────────────
check_command() {
command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 is required but not installed."; exit 1; }
}
check_command aws
check_command node
check_command python3
check_command docker
python3 -c "import sys; assert sys.version_info >= (3,8), 'Python 3.8+ required'" || exit 1
aws sts get-caller-identity --query "Account" --output text >/dev/null 2>&1 || \
{ echo "ERROR: AWS credentials not configured. Run: aws configure"; exit 1; }
echo "✓ Preflight checks passed"
# ── Patch config.yaml (pattern + stack name suffix) ──────────────────────────
python3 - "$CONFIG" "$PATTERN" "$SUFFIX" <<'PYEOF'
import re, sys
config_path, pattern, suffix = sys.argv[1], sys.argv[2], sys.argv[3]
with open(config_path) as f:
content = f.read()
# Patch pattern
content = re.sub(r"(pattern:\s*)[\w-]+", r"\g<1>" + pattern, content)
# Patch stack_name_base: strip any existing -lg/-st suffix, append this script's suffix
def add_suffix(m):
base = re.sub(r"-(lg|st)$", "", m.group(1))
return f"stack_name_base: {base}{suffix}"
content = re.sub(r"stack_name_base:\s*([\w-]+)", add_suffix, content)
with open(config_path, "w") as f:
f.write(content)
# Read back the final stack name for display
stack = re.search(r"stack_name_base:\s*([\w-]+)", content).group(1)
print(f"✓ config.yaml → pattern: {pattern}, stack: {stack}")
PYEOF
# ── CDK deploy ───────────────────────────────────────────────────────────────
if [ "$SKIP_BACKEND" = true ]; then
echo "⚡ Skipping backend deploy (--skip-backend)"
else
echo "Deploying infrastructure (this takes ~1015 min on first run)..."
cd "$CDK_DIR"
npm install --silent
npx cdk@latest deploy --all --require-approval never --output "${SCRIPT_DIR}/cdk.out${SUFFIX}"
cd "$SCRIPT_DIR"
echo "✓ Infrastructure deployed"
fi
# ── Frontend deploy ───────────────────────────────────────────────────────────
if [ "$SKIP_FRONTEND" = true ]; then
echo "⚡ Skipping frontend deploy (--skip-frontend)"
else
STACK_NAME=$(python3 -c "import re; c=open('$CONFIG').read(); print(re.search(r'stack_name_base:\s*([\w-]+)', c).group(1))")
echo "Deploying frontend for stack: $STACK_NAME"
python3 scripts/deploy-frontend.py "$STACK_NAME"
fi
echo ""
echo "✓ Done!"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# deploy-strands.sh — Deploy CopilotKit + AWS Strands on AWS AgentCore
# Usage: ./deploy-strands.sh [--skip-frontend] [--skip-backend]
# Stack: <stack_name_base>-st (isolated from deploy-langgraph.sh)
# Using Terraform instead? See infra-terraform/README.md
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PATTERN="strands-single-agent"
SUFFIX="-st"
CONFIG="$SCRIPT_DIR/config.yaml"
CDK_DIR="$SCRIPT_DIR/infra-cdk"
SKIP_FRONTEND=false
SKIP_BACKEND=false
for arg in "$@"; do
[[ "$arg" == "--skip-frontend" ]] && SKIP_FRONTEND=true
[[ "$arg" == "--skip-backend" ]] && SKIP_BACKEND=true
done
echo "── CopilotKit + AWS AgentCore (Strands) ────────────────────────────────"
# ── Preflight checks ──────────────────────────────────────────────────────────
check_command() {
command -v "$1" >/dev/null 2>&1 || { echo "ERROR: $1 is required but not installed."; exit 1; }
}
check_command aws
check_command node
check_command python3
check_command docker
python3 -c "import sys; assert sys.version_info >= (3,8), 'Python 3.8+ required'" || exit 1
aws sts get-caller-identity --query "Account" --output text >/dev/null 2>&1 || \
{ echo "ERROR: AWS credentials not configured. Run: aws configure"; exit 1; }
echo "✓ Preflight checks passed"
# ── Patch config.yaml (pattern + stack name suffix) ──────────────────────────
python3 - "$CONFIG" "$PATTERN" "$SUFFIX" <<'PYEOF'
import re, sys
config_path, pattern, suffix = sys.argv[1], sys.argv[2], sys.argv[3]
with open(config_path) as f:
content = f.read()
# Patch pattern
content = re.sub(r"(pattern:\s*)[\w-]+", r"\g<1>" + pattern, content)
# Patch stack_name_base: strip any existing -lg/-st suffix, append this script's suffix
def add_suffix(m):
base = re.sub(r"-(lg|st)$", "", m.group(1))
return f"stack_name_base: {base}{suffix}"
content = re.sub(r"stack_name_base:\s*([\w-]+)", add_suffix, content)
with open(config_path, "w") as f:
f.write(content)
stack = re.search(r"stack_name_base:\s*([\w-]+)", content).group(1)
print(f"✓ config.yaml → pattern: {pattern}, stack: {stack}")
PYEOF
# ── CDK deploy ───────────────────────────────────────────────────────────────
if [ "$SKIP_BACKEND" = true ]; then
echo "⚡ Skipping backend deploy (--skip-backend)"
else
echo "Deploying infrastructure (this takes ~1015 min on first run)..."
cd "$CDK_DIR"
npm install --silent
npx cdk@latest deploy --all --require-approval never --output "${SCRIPT_DIR}/cdk.out${SUFFIX}"
cd "$SCRIPT_DIR"
echo "✓ Infrastructure deployed"
fi
# ── Frontend deploy ───────────────────────────────────────────────────────────
if [ "$SKIP_FRONTEND" = true ]; then
echo "⚡ Skipping frontend deploy (--skip-frontend)"
else
STACK_NAME=$(python3 -c "import re; c=open('$CONFIG').read(); print(re.search(r'stack_name_base:\s*([\w-]+)', c).group(1))")
echo "Deploying frontend for stack: $STACK_NAME"
python3 scripts/deploy-frontend.py "$STACK_NAME"
fi
echo ""
echo "✓ Done!"
@@ -0,0 +1,24 @@
# ── Stack ──────────────────────────────────────────────────────────────────────
# Get these from your deployed stack outputs:
# aws cloudformation describe-stacks --stack-name <name> --query "Stacks[0].Outputs"
STACK_NAME=my-copilotkit-agentcore-lg # or -st for Strands
MEMORY_ID= # MemoryArn last segment (after final /)
# ── AWS credentials ────────────────────────────────────────────────────────────
# Docker containers can't read ~/.aws/credentials — paste your creds here.
# Run: aws configure export-credentials --format env (for SSO / temp creds)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_SESSION_TOKEN= # leave blank if using long-term creds
AWS_DEFAULT_REGION=us-east-1
# ── Agent selection ────────────────────────────────────────────────────────────
# Which agent to run locally: langgraph or strands (default: strands)
AGENT=strands
# ── CopilotKit Intelligence / Threads (optional) ──────────────────────────────
# Enables persistent Threads in the CopilotKit bridge + frontend.
COPILOTKIT_LICENSE_TOKEN=
INTELLIGENCE_API_KEY=
INTELLIGENCE_API_URL=http://localhost:4201
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
@@ -0,0 +1,21 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Start development server (--host exposes to Docker network)
CMD ["npm", "run", "dev", "--", "--host"]
@@ -0,0 +1,100 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Use ./up.sh instead of docker compose directly —
# it resolves STACK_NAME + MEMORY_ID and generates a local aws-exports.json.
#
# Requires a deployed AWS stack. See ../docs/LOCAL_DEVELOPMENT.md.
#
# Frontend hot reloads on save (volume mount + Vite).
# Agent changes require: docker compose up --build agent
services:
agent:
build:
context: ..
dockerfile: agents/${AGENT:-langgraph}-single-agent/Dockerfile
platforms:
- linux/arm64
ports:
- "8080:8080"
environment:
- MEMORY_ID=${MEMORY_ID}
- STACK_NAME=${STACK_NAME}
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1}
- AWS_REGION=${AWS_DEFAULT_REGION:-us-east-1}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN}
- AGUI_ENABLED=true
- GATEWAY_CREDENTIAL_PROVIDER_NAME=${STACK_NAME}-runtime-gateway-auth
- OTEL_SDK_DISABLED=true
develop:
watch:
- action: sync+restart
path: ../agents/${AGENT:-langgraph}-single-agent
target: /app
ignore:
- __pycache__/
- "*.pyc"
- action: sync+restart
path: ../agents/utils
target: /app/utils
ignore:
- __pycache__/
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8080/ping', timeout=2)",
]
interval: 30s
timeout: 3s
retries: 3
start_period: 15s
networks:
- agentcore-network
bridge:
build:
context: ../infra-cdk/lambdas/copilotkit-runtime
dockerfile: ../../../docker/Dockerfile.bridge.dev
ports:
- "3001:3001"
environment:
- AGENTCORE_AG_UI_URL=http://agent:8080/invocations
- PORT=3001
- OTEL_SDK_DISABLED=true
- COPILOTKIT_LICENSE_TOKEN=${COPILOTKIT_LICENSE_TOKEN}
- INTELLIGENCE_API_KEY=${INTELLIGENCE_API_KEY}
- INTELLIGENCE_API_URL=${INTELLIGENCE_API_URL:-http://localhost:4201}
- INTELLIGENCE_GATEWAY_WS_URL=${INTELLIGENCE_GATEWAY_WS_URL:-ws://localhost:4401}
depends_on:
agent:
condition: service_healthy
networks:
- agentcore-network
frontend:
build:
context: ../frontend
dockerfile: ../docker/Dockerfile.frontend.dev
ports:
- "3000:3000"
volumes:
- ../frontend:/app
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_COPILOTKIT_THREADS_ENABLED=${COPILOTKIT_LICENSE_TOKEN:+true}
depends_on:
bridge:
condition: service_started
networks:
- agentcore-network
networks:
agentcore-network:
driver: bridge
@@ -0,0 +1,35 @@
"""
Resolves STACK_NAME and MEMORY_ID from config.yaml + CloudFormation
and writes them to /env/agent.env for the agent container.
"""
import boto3
import os
import re
import yaml
with open("/config.yaml") as f:
cfg = yaml.safe_load(f)
base = re.sub(r"-(lg|st)$", "", cfg["stack_name_base"])
agent = os.environ.get("AGENT", "langgraph")
suffix = "lg" if agent == "langgraph" else "st"
stack_name = f"{base}-{suffix}"
cf = boto3.client(
"cloudformation", region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
)
stacks = cf.describe_stacks(StackName=stack_name)["Stacks"]
outputs = {
o["OutputKey"]: o["OutputValue"] for s in stacks for o in s.get("Outputs", [])
}
memory_arn = outputs.get("MemoryArn", "")
memory_id = memory_arn.split("/")[-1] if "/" in memory_arn else memory_arn
os.makedirs("/out", exist_ok=True)
with open("/out/agent.env", "w") as f:
f.write(f"STACK_NAME={stack_name}\n")
f.write(f"MEMORY_ID={memory_id}\n")
print(f"Stack: {stack_name} | Memory: {memory_id}")
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Convenience wrapper — auto-fills .env with stack outputs, generates a local
# aws-exports.json pointing at localhost, then runs docker compose.
#
# Usage: ./up.sh [--build]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG="$SCRIPT_DIR/../config.yaml"
ENV_FILE="$SCRIPT_DIR/.env"
[[ -f "$ENV_FILE" ]] || { echo "ERROR: .env not found. Run: cp .env.example .env"; exit 1; }
# Read AGENT from .env
AGENT=$(grep "^AGENT=" "$ENV_FILE" 2>/dev/null | cut -d= -f2 || echo "langgraph")
AGENT="${AGENT:-langgraph}"
# Derive stack name from config.yaml
BASE=$(python3 -c "
import re, yaml
cfg = yaml.safe_load(open('$CONFIG'))
print(re.sub(r'-(lg|st)$', '', cfg['stack_name_base']))
")
SUFFIX="st" && [[ "$AGENT" == "langgraph" ]] && SUFFIX="lg"
STACK_NAME="${BASE}-${SUFFIX}"
echo "Agent: $AGENT | Resolving stack: $STACK_NAME..."
OUTPUTS=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs" \
--output json)
python3 - "$OUTPUTS" "$STACK_NAME" "$ENV_FILE" "$SCRIPT_DIR/../frontend/public" "$AGENT" <<'PYEOF'
import json, os, sys, re
outputs_json, stack_name, env_file, public_dir, agent = sys.argv[1:]
outputs = {o["OutputKey"]: o["OutputValue"] for o in json.loads(outputs_json)}
# Patch STACK_NAME and MEMORY_ID into .env
memory_arn = outputs.get("MemoryArn", "")
memory_id = memory_arn.split("/")[-1] if "/" in memory_arn else memory_arn
with open(env_file) as f:
content = f.read()
for key, val in [("STACK_NAME", stack_name), ("MEMORY_ID", memory_id)]:
if re.search(rf"^{key}=", content, re.MULTILINE):
content = re.sub(rf"^{key}=.*", f"{key}={val}", content, flags=re.MULTILINE)
else:
content += f"\n{key}={val}"
with open(env_file, "w") as f:
f.write(content)
# Generate local aws-exports.json pointing at localhost
pool_id = outputs.get("CognitoUserPoolId", "")
client_id = outputs.get("CognitoClientId", "")
runtime_arn = outputs.get("RuntimeArn", "")
pattern = "langgraph-single-agent" if agent == "langgraph" else "strands-single-agent"
aws_exports = {
"authority": f"https://cognito-idp.us-east-1.amazonaws.com/{pool_id}",
"client_id": client_id,
"redirect_uri": "http://localhost:3000",
"post_logout_redirect_uri": "http://localhost:3000",
"response_type": "code",
"scope": "email openid profile",
"automaticSilentRenew": True,
"agentRuntimeArn": runtime_arn,
"awsRegion": "us-east-1",
"copilotKitRuntimeUrl": "http://localhost:3001/copilotkit",
"agentPattern": pattern,
}
os.makedirs(public_dir, exist_ok=True)
with open(f"{public_dir}/aws-exports.json", "w") as f:
json.dump(aws_exports, f, indent=2)
print(f"✓ Stack: {stack_name} | Memory: {memory_id}")
print(f"✓ aws-exports.json → localhost:3001")
PYEOF
set -a && source "$ENV_FILE" 2>/dev/null || true && set +a
AGENT="$AGENT" STACK_NAME="$STACK_NAME" \
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up --watch "$@"
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A solution template for building GenAI applications with AgentCore"
/>
<title>Fullstack AgentCore Solution Template</title>
<!-- Google Fonts for Geist Sans and Geist Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist+Sans:wght@100..900&family=Geist+Mono:wght@100..900&display=swap"
rel="stylesheet"
/>
<!--
Set the theme class BEFORE first paint to avoid a white→dark flash.
useTheme applies the theme in a useEffect (post-mount), so without this the
app paints unthemed (light) first, then flips. This blocking inline script
matches useTheme's "system" default (light/dark on <html>) so there's no
flash and no mismatch when the provider re-applies.
-->
<script>
(function () {
try {
var d = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.add(d ? "dark" : "light");
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
{
"name": "fullstack-agentcore-solution-template-frontend",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest --run",
"test:watch": "vitest",
"lint:fix": "eslint src/ --fix",
"clean": "rm -rf build/ node_modules/ .vite/"
},
"dependencies": {
"@copilotkit/react-core": "1.62.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.10",
"@types/react-syntax-highlighter": "^15.5.13",
"aws-amplify": "^6.16.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"oidc-client-ts": "^3.5.0",
"radix-ui": "^1.4.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-dropzone": "^14.3.8",
"react-is": "^19.2.7",
"react-markdown": "^10.1.0",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.21.0",
"react-spinners": "^0.17.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.2.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@shadcn/ui": "^0.0.4",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/node": "^25.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.2.0",
"fast-check": "^4.5.3",
"jsdom": "^23.0.1",
"prettier": "^3.8.1",
"shadcn": "^3.0.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.2.9",
"typescript": "^5",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}
@@ -0,0 +1,7 @@
import tailwindcss from "@tailwindcss/postcss";
const config = {
plugins: [tailwindcss],
};
export default config;
@@ -0,0 +1,16 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "@/components/auth/AuthProvider";
import AppRoutes from "./routes";
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);
}
@@ -0,0 +1,48 @@
"use client";
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/**
* Global context provider for the application
* Provides shared state and functionality across components
*/
import { createContext, useContext, PropsWithChildren, useState } from "react";
interface GlobalContextType {
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
}
const GlobalContext = createContext<GlobalContextType | undefined>(undefined);
/**
* Hook to access the global context
* @returns The global context value
* @throws Error if used outside of GlobalContextProvider
*/
export function useGlobal(): GlobalContextType {
const context = useContext(GlobalContext);
if (context === undefined) {
throw new Error("useGlobal must be used within a GlobalContextProvider");
}
return context;
}
/**
* Global context provider component
* Wraps the application to provide global state
* @param children - Child components to wrap
*/
export function GlobalContextProvider({ children }: PropsWithChildren) {
const [isLoading, setIsLoading] = useState(false);
const value: GlobalContextType = {
isLoading,
setIsLoading,
};
return (
<GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
);
}
@@ -0,0 +1,75 @@
"use client";
import { createCognitoAuthConfig, cognitoAuthConfig } from "@/lib/auth";
import { useEffect, useState, PropsWithChildren } from "react";
import { AuthProvider as OidcAuthProvider } from "react-oidc-context";
import { WebStorageStateStore } from "oidc-client-ts";
import { AutoSignin } from "./AutoSignin";
interface CognitoAuthConfig {
authority?: string;
client_id?: string;
redirect_uri?: string;
post_logout_redirect_uri?: string;
response_type?: string;
scope?: string;
automaticSilentRenew?: boolean;
userStore?: WebStorageStateStore;
}
const AuthProvider = ({ children }: PropsWithChildren) => {
const [authConfig, setAuthConfig] = useState<CognitoAuthConfig | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadConfig() {
try {
const config = await createCognitoAuthConfig();
setAuthConfig(config);
} catch (error) {
console.error("Failed to load auth configuration:", error);
console.error("Falling back to environment variables");
// Fallback to env vars on error
setAuthConfig(cognitoAuthConfig);
} finally {
setLoading(false);
}
}
loadConfig();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen text-xl">
Loading authentication configuration...
</div>
);
}
if (!authConfig) {
return (
<div className="flex items-center justify-center min-h-screen text-xl">
Failed to load authentication configuration
</div>
);
}
return (
<OidcAuthProvider
{...authConfig}
// This callback removes the `?code=` from the URL, which will break page refreshes
onSigninCallback={() => {
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
}}
>
<AutoSignin>{children}</AutoSignin>
</OidcAuthProvider>
);
};
export { AuthProvider };
@@ -0,0 +1,42 @@
"use client";
import { ReactNode, useEffect, useState, PropsWithChildren } from "react";
import { useAuth } from "react-oidc-context";
import { Button } from "@/components/ui/button";
function AutoSigninContent({ children }: PropsWithChildren) {
const auth = useAuth();
if (auth.isLoading) {
return (
<div className="flex items-center justify-center min-h-screen text-xl">
Loading...
</div>
);
}
if (!auth.isAuthenticated) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
<p className="text-4xl">Please sign in</p>
<Button onClick={() => auth.signinRedirect()}>Sign In</Button>
</div>
);
}
return <>{children}</>;
}
export function AutoSignin({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return <AutoSigninContent>{children}</AutoSigninContent>;
}
@@ -0,0 +1,22 @@
// The canvas is always visible alongside the chat pane (spec: "render chat plus a
// todo canvas in the same page shell"). It shows an empty state when there are no
// todos, and fills in as the agent or user adds items.
import { useAgent } from "@copilotkit/react-core/v2";
import { TodoList } from "./TodoList";
import type { Todo } from "./types";
export function TodoCanvas() {
const { agent } = useAgent();
return (
<div className="h-full overflow-y-auto bg-white dark:bg-neutral-950 [background-image:radial-gradient(circle,#d5d5d5_1px,transparent_1px)] dark:[background-image:radial-gradient(circle,#333_1px,transparent_1px)] [background-size:20px_20px]">
<div className="max-w-4xl mx-auto px-8 py-10 h-full">
<TodoList
todos={(agent.state as { todos?: Todo[] })?.todos ?? []}
onUpdate={(updatedTodos) => agent.setState({ todos: updatedTodos })}
isAgentRunning={agent.isRunning}
/>
</div>
</div>
);
}
@@ -0,0 +1,222 @@
import { useState, useRef, useEffect } from "react";
import type { Todo } from "./types";
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()) {
// Don't save empty value — keep the editor open
return;
}
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 (
<div
className={`group relative rounded-2xl p-5 transition-all duration-150 border ${
isCompleted
? "bg-neutral-100 border-neutral-200 dark:bg-neutral-800/50 dark:border-neutral-700"
: "bg-white border-neutral-300 dark:bg-neutral-800 dark:border-neutral-700"
}`}
>
{/* Delete button — visible on hover */}
<button
onClick={() => onDelete(todo)}
className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-100 cursor-pointer rounded-full p-1 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300"
aria-label="Delete todo"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Emoji avatar */}
<div className="relative inline-block mb-3">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className={`block text-3xl leading-none cursor-pointer rounded-xl p-2 transition-colors duration-100 ${
isCompleted
? "bg-neutral-200 dark:bg-neutral-700"
: "bg-neutral-100 dark:bg-neutral-700/50"
}`}
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-white border border-neutral-300 shadow-lg dark:bg-neutral-800 dark:border-neutral-600">
{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-neutral-100 dark:hover:bg-neutral-700"
>
{emoji}
</button>
))}
</div>
)}
</div>
{/* Title + description */}
<div className="flex items-start gap-3">
<button
onClick={() => onToggleStatus(todo)}
className="flex-shrink-0 mt-[2px] cursor-pointer"
aria-label={isCompleted ? "Mark as incomplete" : "Mark as complete"}
>
{isCompleted ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect
x="1"
y="1"
width="18"
height="18"
rx="6"
className="fill-neutral-900 dark:fill-neutral-100"
/>
<path
d="M6 10.5L8.5 13L14 7"
className="stroke-white dark:stroke-neutral-900"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect
x="1"
y="1"
width="18"
height="18"
rx="6"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
</svg>
)}
</button>
<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-[16px] font-semibold focus:outline-none bg-transparent text-neutral-900 dark:text-neutral-100 border-b-2 border-neutral-900 dark:border-neutral-100 pb-[2px]"
autoFocus
aria-label="Edit todo title"
/>
) : (
<div
onClick={() => startEdit("title")}
className={`text-[16px] font-semibold cursor-text break-words leading-snug ${
isCompleted
? "text-neutral-400 line-through dark:text-neutral-500"
: "text-neutral-900 dark:text-neutral-100"
}`}
>
{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-[14px] leading-relaxed focus:outline-none resize-none bg-transparent text-neutral-500 dark:text-neutral-400 border-b-2 border-neutral-900 dark:border-neutral-100 pb-[2px]"
rows={1}
autoFocus
aria-label="Edit todo description"
/>
) : (
<p
onClick={() => startEdit("description")}
className={`mt-1.5 text-[14px] leading-relaxed cursor-text ${
isCompleted
? "text-neutral-300 line-through dark:text-neutral-600"
: "text-neutral-500 dark:text-neutral-400"
}`}
>
{truncatedDescription}
</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import type { Todo } from "./types";
import { TodoCard } from "./TodoCard";
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">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-[18px] font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
{title}
</h2>
<span className="text-[12px] font-semibold rounded-full px-2 py-0.5 text-neutral-500 bg-neutral-200 dark:text-neutral-400 dark:bg-neutral-700">
{todos.length}
</span>
</div>
{showAddButton && onAddTodo && (
<button
onClick={onAddTodo}
className="rounded-full cursor-pointer transition-colors p-1.5 text-neutral-500 bg-neutral-200 hover:bg-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:hover:text-neutral-100"
aria-label="Add new todo"
disabled={isAgentRunning}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
)}
</div>
<div className="space-y-4">
{todos.length === 0 ? (
<div className="text-center text-[14px] rounded-2xl border-2 border-dashed p-5 min-h-[151px] flex items-center justify-center text-neutral-400 border-neutral-300 dark:text-neutral-500 dark:border-neutral-700">
{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,104 @@
import type { Todo } from "./types";
import { TodoColumn } from "./TodoColumn";
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) => {
onUpdate(
todos.map((t) =>
t.id === todo.id
? {
...t,
status: t.status === "completed" ? "pending" : "completed",
}
: t,
),
);
};
const deleteTodo = (todo: Todo) => {
onUpdate(todos.filter((t) => t.id !== todo.id));
};
const updateTitle = (todoId: string, title: string) => {
onUpdate(todos.map((t) => (t.id === todoId ? { ...t, title } : t)));
};
const updateDescription = (todoId: string, description: string) => {
onUpdate(todos.map((t) => (t.id === todoId ? { ...t, description } : t)));
};
const updateEmoji = (todoId: string, emoji: string) => {
onUpdate(todos.map((t) => (t.id === todoId ? { ...t, emoji } : t)));
};
const addTodo = () => {
const newTodo: Todo = {
id: crypto.randomUUID(),
title: "New Todo",
description: "Add a description",
emoji: "🎯",
status: "pending",
};
onUpdate([...todos, newTodo]);
};
if (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-[16px] font-semibold text-neutral-900 dark:text-neutral-100">
No tasks yet
</p>
<p className="text-[14px] text-neutral-500 dark:text-neutral-400">
Create your first task to get started
</p>
<button
onClick={addTodo}
className="mt-2 px-5 py-2.5 text-[14px] font-semibold rounded-full cursor-pointer transition-colors text-white bg-neutral-900 hover:bg-neutral-700 dark:text-neutral-900 dark:bg-neutral-100 dark:hover:bg-neutral-300"
aria-label="Add your first todo task"
disabled={isAgentRunning}
>
Add a task
</button>
</div>
);
}
return (
<div className="flex gap-8 h-full">
<TodoColumn
title="To Do"
todos={pendingTodos}
emptyMessage="No pending tasks"
showAddButton
onAddTodo={addTodo}
onToggleStatus={toggleStatus}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateDescription={updateDescription}
onUpdateEmoji={updateEmoji}
isAgentRunning={isAgentRunning}
/>
<TodoColumn
title="Done"
todos={completedTodos}
emptyMessage="No completed tasks yet"
onToggleStatus={toggleStatus}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateDescription={updateDescription}
onUpdateEmoji={updateEmoji}
isAgentRunning={isAgentRunning}
/>
</div>
);
}
@@ -0,0 +1,7 @@
export interface Todo {
id: string;
title: string;
description: string;
emoji: string;
status: "pending" | "completed";
}
@@ -0,0 +1,48 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default `--cpk-drawer-width`, 320px)
as a fixed first column so the layout does NOT shift when the client-only
drawer mounts. On mobile the drawer is an off-canvas overlay (out of flow),
so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
/*
Align the drawer's mobile launcher with this app's header controls. These
custom properties inherit into <copilotkit-threads-drawer> and pierce its shadow
root; tuned to match the example header's top-left inset.
*/
--cpk-drawer-launcher-top: 7px;
--cpk-drawer-launcher-left: 16px;
}
.mainPanel {
/*
Pin the content to the SECOND grid track explicitly. The client-only
<CopilotThreadsDrawer> renders nothing during the drawer's mount gate, so without
an explicit placement the panel would flow into the reserved first track
and then jump once the drawer mounts. Forcing column 2 keeps it put.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise leak the desktop layout).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -0,0 +1,66 @@
import { useEffect, useRef } from "react";
interface ToolReasoningProps {
name: string;
args?: object | unknown;
status: string;
}
const statusIndicator = {
executing: (
<span className="inline-block h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin" />
),
inProgress: (
<span className="inline-block h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin" />
),
complete: <span className="text-green-500 text-xs"></span>,
};
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 as Record<string, unknown>) : [];
const detailsRef = useRef<HTMLDetailsElement>(null);
const toolStatus = status as "complete" | "inProgress" | "executing";
// Auto-open while executing, auto-close when complete
useEffect(() => {
if (!detailsRef.current) return;
detailsRef.current.open = status === "executing";
}, [status]);
return (
<div className="my-2 text-sm">
{entries.length > 0 ? (
<details ref={detailsRef} open>
<summary className="flex items-center gap-2 text-gray-600 dark:text-gray-400 cursor-pointer list-none">
{statusIndicator[toolStatus]}
<span className="font-medium">{name}</span>
<span className="text-[10px]"></span>
</summary>
<div className="pl-5 mt-1 space-y-1 text-xs text-gray-500 dark:text-zinc-400">
{entries.map(([key, value]) => (
<div key={key} className="flex gap-2 min-w-0">
<span className="font-medium shrink-0">{key}:</span>
<span className="text-gray-600 dark:text-gray-400 truncate">
{formatValue(value)}
</span>
</div>
))}
</div>
</details>
) : (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
{statusIndicator[toolStatus]}
<span className="font-medium">{name}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,185 @@
// frontend/src/components/chat/CopilotChatInterface.tsx
"use client";
import "@copilotkit/react-core/v2/styles.css";
import { useEffect, useMemo, useState } from "react";
import {
CopilotChat,
CopilotChatConfigurationProvider,
CopilotThreadsDrawer,
CopilotKitProvider,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import { useAuth as useOidcAuth } from "react-oidc-context";
import { loadAwsConfig } from "@/lib/runtime-config";
import type { AwsExportsConfig } from "@/lib/runtime-config";
import { useExampleSuggestions } from "@/hooks/useExampleSuggestions";
import { useCopilotExamples } from "@/hooks/useCopilotExamples";
import { ThemeProvider } from "@/hooks/useTheme";
import { TodoCanvas } from "@/components/canvas/TodoCanvas";
import { ModeToggle } from "@/components/ui/mode-toggle";
import styles from "./CopilotKit.module.css";
const COPILOTKIT_AGENT_ID = "default";
type ResolvedAwsExportsConfig = AwsExportsConfig & {
copilotKitRuntimeUrl: string;
};
function CopilotChatContent() {
const [mode, setMode] = useState<"chat" | "app">("chat");
useExampleSuggestions();
useCopilotExamples();
useFrontendTool({
name: "enableAppMode",
description: "Enable app mode when working with the todo canvas.",
handler: async () => {
setMode("app");
},
});
useFrontendTool({
name: "enableChatMode",
description: "Enable chat mode",
handler: async () => {
setMode("chat");
},
});
return (
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotThreadsDrawer> drives it
directly — picking a row sets the active thread, "+ New" resets to a fresh
thread — with no host thread-state. The drawer inherits `runtimeUrl` and
the Cognito auth `headers` from the surrounding <CopilotKitProvider> (via
useThreads -> useCopilotKit), so threads are fetched authenticated with no
explicit props. A *controlled* provider would block "+ New" from
resetting, so uncontrolled-inside-provider is required, not optional.
*/
<CopilotChatConfigurationProvider agentId={COPILOTKIT_AGENT_ID}>
<div className={styles.layout}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated: the locked view's Upgrade CTA opens the Intelligence docs by default. */}
<CopilotThreadsDrawer agentId={COPILOTKIT_AGENT_ID} />
<div className={styles.mainPanel}>
<div className="h-full flex flex-row">
<ModeToggle mode={mode} onModeChange={setMode} />
<div
className={`max-h-full overflow-y-auto [&_.copilotKitChat]:h-full [&_.copilotKitChat]:border-0 [&_.copilotKitChat]:shadow-none ${
mode === "app"
? "w-1/2 px-6 max-lg:hidden"
: "flex-1 px-4 lg:px-6"
}`}
>
<CopilotChat agentId={COPILOTKIT_AGENT_ID} className="h-full" />
</div>
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-1/2 border-l dark:border-zinc-700 max-lg:w-full max-lg:border-l-0"
: "w-0 border-l-0"
}`}
>
{/*
Fill the state panel's own width. The previous `lg:w-[66.666vw]`
was viewport-relative, so with a reserved drawer column it
overflowed this container (clipped by overflow-hidden) and
pushed centered content right of the visible box's center.
*/}
<div className="h-full w-full">
<TodoCanvas />
</div>
</div>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
function CopilotKitShell({
config,
accessToken,
}: {
config: ResolvedAwsExportsConfig;
accessToken: string | undefined;
}) {
const headers = useMemo(
() =>
accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
[accessToken],
);
return (
<CopilotKitProvider
runtimeUrl={config.copilotKitRuntimeUrl}
headers={headers}
useSingleEndpoint={false}
>
<CopilotChatContent />
</CopilotKitProvider>
);
}
export default function CopilotChatInterface() {
const auth = useOidcAuth();
const [config, setConfig] = useState<AwsExportsConfig | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
async function resolveConfig() {
try {
const runtimeConfig = await loadAwsConfig();
if (!isMounted) return;
if (!runtimeConfig || !runtimeConfig.copilotKitRuntimeUrl) {
throw new Error("CopilotKit runtime URL not found in configuration");
}
setConfig(runtimeConfig);
} catch (err) {
if (!isMounted) return;
const message = err instanceof Error ? err.message : "Unknown error";
setError(`Configuration error: ${message}`);
}
}
resolveConfig();
return () => {
isMounted = false;
};
}, []);
if (error) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-red-600">
{error}
</div>
);
}
if (!config) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm">
Loading CopilotKit configuration...
</div>
);
}
const accessToken = auth.user?.access_token ?? auth.user?.id_token;
return (
<ThemeProvider>
<div className="h-full bg-[#f5f7fb]">
<CopilotKitShell
config={config as ResolvedAwsExportsConfig}
accessToken={accessToken}
/>
</div>
</ThemeProvider>
);
}
@@ -0,0 +1,32 @@
// Define message types
export type MessageRole = "user" | "assistant";
export type ToolCallStatus = "streaming" | "executing" | "complete";
export interface ToolCall {
toolUseId: string;
name: string;
input: string;
result?: string;
status: ToolCallStatus;
}
export type MessageSegment =
| { type: "text"; content: string }
| { type: "tool"; toolCall: ToolCall };
export interface Message {
role: MessageRole;
content: string;
timestamp: string;
segments?: MessageSegment[];
}
// Define chat session types
export interface ChatSession {
id: string;
name: string;
history: Message[];
startDate: string;
endDate: string;
}
@@ -0,0 +1,94 @@
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { z } from "zod";
const CHART_COLORS = [
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#f59e0b",
"#10b981",
"#06b6d4",
"#f97316",
];
const TOOLTIP_STYLE = {
backgroundColor: "var(--chart-tooltip-bg)",
border: "1px solid var(--chart-tooltip-border)",
borderRadius: "8px",
padding: "8px 12px",
color: "var(--foreground)",
};
export const BarChartPropsSchema = 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 BarChartPropsSchema>;
export function BarChart({ title, description, data }: BarChartProps) {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-2xl mx-auto my-6 bg-[var(--background)]">
<div className="mb-4">
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
<p className="text-sm text-gray-600 dark:text-zinc-400">
{description}
</p>
</div>
<p className="text-gray-500 dark:text-zinc-400 text-center py-8">
No data available
</p>
</div>
);
}
const coloredData = data.map((entry, index) => ({
...entry,
fill: CHART_COLORS[index % CHART_COLORS.length],
}));
return (
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-2xl mx-auto my-6 bg-[var(--background)]">
<div className="mb-4">
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
<p className="text-sm text-gray-600 dark:text-zinc-400">
{description}
</p>
</div>
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart
data={coloredData}
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
>
<XAxis
dataKey="label"
tick={{ fontSize: 12 }}
stroke="var(--chart-axis)"
/>
<YAxis tick={{ fontSize: 12 }} stroke="var(--chart-axis)" />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Bar
isAnimationActive={false}
dataKey="value"
radius={[4, 4, 0, 0]}
/>
</RechartsBarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,134 @@
import { useState } from "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.",
);
};
return (
<div className="rounded-2xl shadow-lg max-w-md w-full border dark:border-zinc-700 mx-auto mb-6 bg-white dark:bg-zinc-800">
<div className="backdrop-blur-md p-8 w-full rounded-2xl">
{selectedSlot ? (
<div className="text-center">
<div className="text-7xl mb-4">📅</div>
<h2 className="text-2xl font-bold mb-2 dark:text-white">
Meeting Scheduled
</h2>
<p className="text-gray-600 dark:text-zinc-400 mb-2">
{selectedSlot.date} at {selectedSlot.time}
</p>
{selectedSlot.duration && (
<p className="text-sm text-gray-500 dark:text-zinc-400">
Duration: {selectedSlot.duration}
</p>
)}
</div>
) : declined ? (
<div className="text-center">
<div className="text-7xl mb-4">🔄</div>
<h2 className="text-2xl font-bold mb-2 dark:text-white">
No Time Selected
</h2>
<p className="text-gray-600 dark:text-zinc-400">
Let me find a better time that works for you
</p>
</div>
) : (
<>
<div className="text-center mb-6">
<div className="text-7xl mb-4">🗓</div>
<h2 className="text-2xl font-bold mb-2 dark:text-white">
{displayTitle}
</h2>
<p className="text-gray-600 dark:text-zinc-400">
Select a time that works for you
</p>
</div>
{status === "executing" && (
<div className="space-y-3">
{slots.map((slot, index) => (
<button
key={index}
onClick={() => handleSelectSlot(slot)}
className="w-full px-6 py-4 rounded-xl font-medium
border-2 border-gray-200 dark:border-zinc-600 hover:border-blue-500 dark:hover:border-blue-400
shadow-sm hover:shadow-md transition-all cursor-pointer
flex justify-between items-center
hover:bg-blue-50 dark:hover:bg-blue-900/30"
>
<div className="text-left">
<div className="font-bold text-gray-900 dark:text-zinc-100">
{slot.date}
</div>
<div className="text-sm text-gray-600 dark:text-zinc-400">
{slot.time}
</div>
</div>
{slot.duration && (
<div className="text-sm text-gray-500 dark:text-zinc-400">
{slot.duration}
</div>
)}
</button>
))}
<button
onClick={handleDecline}
className="w-full px-6 py-3 rounded-xl font-medium
text-gray-600 dark:text-zinc-400 hover:text-gray-800 dark:hover:text-zinc-200
transition-all cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-700"
>
None of these work
</button>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,102 @@
import {
PieChart as RechartsPieChart,
Pie,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { z } from "zod";
const CHART_COLORS = [
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#f59e0b",
"#10b981",
"#06b6d4",
"#f97316",
];
const TOOLTIP_STYLE = {
backgroundColor: "var(--chart-tooltip-bg)",
border: "1px solid var(--chart-tooltip-border)",
borderRadius: "8px",
padding: "8px 12px",
color: "var(--foreground)",
};
export const PieChartPropsSchema = 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 PieChartPropsSchema>;
export function PieChart({ title, description, data }: PieChartProps) {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-lg mx-auto my-6 bg-[var(--background)]">
<div className="mb-4">
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
<p className="text-sm text-gray-600 dark:text-zinc-400">
{description}
</p>
</div>
<p className="text-gray-500 dark:text-zinc-400 text-center py-8">
No data available
</p>
</div>
);
}
// Add colors to data
const coloredData = data.map((entry, index) => ({
...entry,
fill: CHART_COLORS[index % CHART_COLORS.length],
}));
return (
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-lg mx-auto my-6 bg-[var(--background)]">
<div className="mb-4">
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
<p className="text-sm text-gray-600 dark:text-zinc-400">
{description}
</p>
</div>
<ResponsiveContainer width="100%" height={300}>
<RechartsPieChart>
<Pie
data={coloredData}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={100}
isAnimationActive={false}
/>
<Tooltip contentStyle={TOOLTIP_STYLE} />
</RechartsPieChart>
</ResponsiveContainer>
{/* Legend */}
<div className="mt-4 grid grid-cols-2 gap-2">
{data.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-sm"
style={{
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span className="text-sm dark:text-zinc-300">{item.label}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
// frontend/src/components/generative-ui/ToolReasoning.tsx
import { useEffect, useRef } from "react";
interface ToolReasoningProps {
name: string;
args?: object | unknown;
status: string;
}
const statusIndicator = {
executing: (
<span className="inline-block h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin" />
),
inProgress: (
<span className="inline-block h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin" />
),
complete: <span className="text-green-500 text-xs"></span>,
};
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 as Record<string, unknown>) : [];
const detailsRef = useRef<HTMLDetailsElement>(null);
const toolStatus = status as "complete" | "inProgress" | "executing";
// Auto-open while executing, auto-close when complete
useEffect(() => {
if (!detailsRef.current) return;
detailsRef.current.open = status === "executing";
}, [status]);
return (
<div className="my-2 text-sm">
{entries.length > 0 ? (
<details ref={detailsRef} open>
<summary className="flex items-center gap-2 text-gray-600 dark:text-gray-400 cursor-pointer list-none">
{statusIndicator[toolStatus]}
<span className="font-medium">{name}</span>
<span className="text-[10px]"></span>
</summary>
<div className="pl-5 mt-1 space-y-1 text-xs text-gray-500 dark:text-zinc-400">
{entries.map(([key, value]) => (
<div key={key} className="flex gap-2 min-w-0">
<span className="font-medium shrink-0">{key}:</span>
<span className="text-gray-600 dark:text-gray-400 truncate">
{formatValue(value)}
</span>
</div>
))}
</div>
</details>
) : (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
{statusIndicator[toolStatus]}
<span className="font-medium">{name}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,21 @@
import { RingLoader } from "react-spinners";
type GenerationProps = {
message: string;
};
const LoadingSpinner = ({ message }: GenerationProps) => {
return (
<div className="p-6 flex flex-col justify-center items-center h-full gap-8">
<RingLoader size={200} color="white" />
<div className="text-center">
<p className="text-4xl font-medium animate-pulse mb-5">{message}</p>
<p className="text-2xl text-slate-100 animate-bounce">
Please stand by...
</p>
</div>
</div>
);
};
export default LoadingSpinner;
@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
@@ -0,0 +1,59 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
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-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,39 @@
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 bg-gray-100 dark:bg-zinc-800 rounded-lg p-1 shadow-sm max-lg:top-2 max-lg:right-2 max-lg:scale-90">
<button
onClick={() => onModeChange("chat")}
className={`
px-4 py-2 rounded-md text-sm font-medium transition-all max-lg:px-3 max-lg:py-1.5 max-lg:text-xs
cursor-pointer
${
mode === "chat"
? "bg-white dark:bg-zinc-700 text-gray-900 dark:text-white shadow-sm"
: "text-gray-600 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-white"
}
`}
>
Chat
</button>
<button
onClick={() => onModeChange("app")}
className={`
px-4 py-2 rounded-md text-sm font-medium transition-all max-lg:px-3 max-lg:py-1.5 max-lg:text-xs
cursor-pointer
${
mode === "app"
? "bg-white dark:bg-zinc-700 text-gray-900 dark:text-white shadow-sm"
: "text-gray-600 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-white"
}
`}
>
App Mode
</button>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
@@ -0,0 +1,185 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -0,0 +1,139 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
@@ -0,0 +1,728 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/UseMobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
return `${(array[0] % 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
@@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
@@ -0,0 +1,71 @@
"use client";
import { useAuth as useOidcAuth } from "react-oidc-context";
import { useEffect, useState } from "react";
import { WebStorageStateStore } from "oidc-client-ts";
import { createCognitoAuthConfig } from "@/lib/auth";
interface CognitoAuthConfig {
authority?: string;
client_id?: string;
redirect_uri?: string;
post_logout_redirect_uri?: string;
response_type?: string;
scope?: string;
automaticSilentRenew?: boolean;
userStore?: WebStorageStateStore;
}
export function useAuth() {
const auth = useOidcAuth();
const [authConfig, setAuthConfig] = useState<CognitoAuthConfig | null>(null);
useEffect(() => {
async function loadConfig() {
try {
const config = await createCognitoAuthConfig();
setAuthConfig(config);
} catch (error) {
console.error("Failed to load auth configuration for signOut:", error);
}
}
loadConfig();
}, []);
// If no AuthProvider context, return mock auth state (no authentication)
if (!auth) {
return {
isAuthenticated: true,
user: null,
signIn: () => {},
signOut: () => {},
isLoading: false,
error: null,
token: null,
};
}
return {
isAuthenticated: auth.isAuthenticated,
user: auth.user,
signIn: auth.signinRedirect,
signOut: () => {
const clientId =
authConfig?.client_id || import.meta.env.VITE_COGNITO_CLIENT_ID || "";
const logoutUri =
authConfig?.redirect_uri ||
import.meta.env.VITE_COGNITO_REDIRECT_URI ||
"http://localhost:3000";
auth.signoutRedirect({
extraQueryParams: {
client_id: clientId,
logout_uri: logoutUri,
},
});
},
isLoading: auth.isLoading,
error: auth.error,
token: auth.user?.id_token,
};
}
@@ -0,0 +1,75 @@
import { z } from "zod";
import {
useComponent,
useFrontendTool,
useHumanInTheLoop,
useDefaultRenderTool,
} from "@copilotkit/react-core/v2";
import {
PieChart,
PieChartPropsSchema,
} from "@/components/generative-ui/PieChart";
import {
BarChart,
BarChartPropsSchema,
} from "@/components/generative-ui/BarChart";
import { ToolReasoning } from "@/components/generative-ui/ToolReasoning";
import { MeetingTimePicker } from "@/components/generative-ui/MeetingTimePicker";
import { useTheme } from "@/hooks/useTheme";
export const useCopilotExamples = () => {
const { theme, setTheme } = useTheme();
// Frontend tool: toggle light/dark mode
useFrontendTool(
{
name: "toggleTheme",
description: "Frontend tool for toggling the theme of the app.",
parameters: z.object({}),
handler: async () => {
setTheme(theme === "dark" ? "light" : "dark");
},
},
[theme, setTheme],
);
// Controlled Generative UI: pie chart
useComponent({
name: "pieChart",
description: "Controlled Generative UI that displays data as a pie chart.",
parameters: PieChartPropsSchema,
render: PieChart,
});
// Controlled Generative UI: bar chart
useComponent({
name: "barChart",
description: "Controlled Generative UI that displays data as a bar chart.",
parameters: BarChartPropsSchema,
render: BarChart,
});
// Default renderer for all backend tool calls
useDefaultRenderTool({
render: ({ name, status, parameters }) => (
<ToolReasoning name={name} status={status} args={parameters} />
),
});
// Human-in-the-loop: meeting scheduler
useHumanInTheLoop({
name: "scheduleTime",
description: "Use human-in-the-loop to schedule a meeting with the user.",
parameters: z.object({
reasonForScheduling: z
.string()
.describe("Reason for scheduling, very brief - 5 words."),
meetingDuration: z
.number()
.describe("Duration of the meeting in minutes"),
}),
render: ({ respond, status, args }) => (
<MeetingTimePicker status={status} respond={respond} {...args} />
),
});
};
@@ -0,0 +1,38 @@
// frontend/src/hooks/useExampleSuggestions.ts
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export const useExampleSuggestions = () => {
useConfigureSuggestions({
suggestions: [
{
title: "Pie chart (Controlled Generative UI)",
message:
"Please show me the distribution of our revenue by category in a pie chart.",
},
{
title: "Bar chart (Controlled Generative UI)",
message:
"Please show me the distribution of our expenses by category in a bar chart.",
},
{
title: "MCP apps (Open Generative UI)",
message:
"Please create a simple network diagram of a router and two switches.",
},
{
title: "Change theme (Frontend Tools)",
message: "Switch the app to dark mode.",
},
{
title: "Scheduling (Human In The Loop)",
message: "Please schedule a meeting with me to learn about CopilotKit.",
},
{
title: "Canvas (Shared State)",
message:
"Please demonstrate shared state, open the canvas, and then add some todos to it about learning about CopilotKit.",
},
],
available: "always",
});
};
@@ -0,0 +1,43 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
const ThemeContext = createContext<{
theme: Theme;
setTheme: (t: Theme) => void;
}>({
theme: "system",
setTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
root.classList.remove("light", "dark");
root.classList.add(mq.matches ? "dark" : "light");
};
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}
root.classList.add(theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import type { ToolCallStatus } from "@/components/chat/types";
export interface ToolRenderProps {
name: string;
args: string;
status: ToolCallStatus;
result?: string;
}
export type ToolRenderFn = (props: ToolRenderProps) => ReactNode;
const renderers = new Map<string, ToolRenderFn>();
export function useDefaultTool(render: ToolRenderFn) {
renderers.set("*", render);
}
export function useToolRenderer(name: string, render: ToolRenderFn) {
renderers.set(name, render);
}
export function getToolRenderer(name: string): ToolRenderFn | null {
return renderers.get(name) ?? renderers.get("*") ?? null;
}
@@ -0,0 +1,116 @@
import { WebStorageStateStore } from "oidc-client-ts";
// Configuration type matching the cognitoAuthConfig structure
type AwsExportsConfig = {
authority?: string;
client_id?: string;
redirect_uri?: string;
post_logout_redirect_uri?: string;
response_type?: string;
scope?: string;
automaticSilentRenew?: boolean;
userStore: WebStorageStateStore | undefined;
};
/**
* Configuration Priority (highest to lowest):
* 1. Environment variables (VITE_COGNITO_*)
* 2. aws-exports.json file
* 3. Default values
*/
// Cache for loaded config
let configCache: AwsExportsConfig | null = null;
let configPromise: Promise<AwsExportsConfig | null> | null = null;
// Load configuration from aws-exports.json at runtime
async function loadAwsConfig(): Promise<AwsExportsConfig | null> {
if (configCache) {
return configCache;
}
if (configPromise) {
return configPromise;
}
configPromise = (async () => {
try {
const response = await fetch("/aws-exports.json");
if (!response.ok) {
throw new Error(`Failed to load aws-exports.json: ${response.status}`);
}
const config = await response.json();
configCache = config;
return config;
} catch (error) {
console.error("Failed to load aws-exports.json:", error);
throw error;
}
})();
return configPromise;
}
// Create auth config factory function that loads config dynamically
export async function createCognitoAuthConfig(): Promise<AwsExportsConfig> {
const awsConfig = await loadAwsConfig();
if (awsConfig === null) {
throw Error("aws-exports.json file not found");
}
// Get environment variables
const userPoolId = import.meta.env.VITE_COGNITO_USER_POOL_ID;
const clientId = import.meta.env.VITE_COGNITO_CLIENT_ID;
const region = import.meta.env.VITE_COGNITO_REGION;
const redirectUri = import.meta.env.VITE_COGNITO_REDIRECT_URI;
const postLogoutRedirectUri = import.meta.env
.VITE_COGNITO_POST_LOGOUT_REDIRECT_URI;
const responseType = import.meta.env.VITE_COGNITO_RESPONSE_TYPE;
const scope = import.meta.env.VITE_COGNITO_SCOPE;
const automaticSilentRenew = import.meta.env
.VITE_COGNITO_AUTOMATIC_SILENT_RENEW;
// Build authority from environment variables if region and userPoolId are provided
const envAuthority =
region && userPoolId
? `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`
: undefined;
return {
authority: envAuthority || awsConfig.authority,
client_id: clientId || awsConfig.client_id,
redirect_uri: redirectUri || awsConfig.redirect_uri,
post_logout_redirect_uri:
postLogoutRedirectUri ||
redirectUri ||
awsConfig.post_logout_redirect_uri,
response_type: responseType || awsConfig.response_type || "code",
scope: scope || awsConfig.scope || "email openid profile",
automaticSilentRenew:
automaticSilentRenew === "false"
? false
: automaticSilentRenew === "true"
? true
: (awsConfig.automaticSilentRenew ?? true),
userStore:
typeof window !== "undefined"
? new WebStorageStateStore({ store: window.localStorage })
: undefined,
};
}
// Synchronous version for backwards compatibility (uses env vars as fallback)
export const cognitoAuthConfig = {
authority: `https://cognito-idp.${import.meta.env.VITE_COGNITO_REGION}.amazonaws.com/${import.meta.env.VITE_COGNITO_USER_POOL_ID}`,
client_id: import.meta.env.VITE_COGNITO_CLIENT_ID,
redirect_uri: import.meta.env.VITE_COGNITO_REDIRECT_URI,
post_logout_redirect_uri: import.meta.env.VITE_COGNITO_REDIRECT_URI,
response_type: "code",
scope: "email openid profile",
automaticSilentRenew: true,
userStore:
typeof window !== "undefined"
? new WebStorageStateStore({ store: window.localStorage })
: undefined,
};
@@ -0,0 +1,48 @@
// Runtime configuration loader for aws-exports.json.
// Used by the CopilotKit integration (components/chat/CopilotKit/) to resolve
// the CopilotKit runtime URL at startup.
export type AwsExportsConfig = {
authority?: string;
client_id?: string;
redirect_uri?: string;
post_logout_redirect_uri?: string;
response_type?: string;
scope?: string;
automaticSilentRenew?: boolean;
agentRuntimeArn?: string;
awsRegion?: string;
feedbackApiUrl?: string;
copilotKitRuntimeUrl?: string;
agentPattern?: string;
};
let configCache: AwsExportsConfig | null = null;
let configPromise: Promise<AwsExportsConfig | null> | null = null;
export async function loadAwsConfig(): Promise<AwsExportsConfig | null> {
if (configCache) {
return configCache;
}
if (configPromise) {
return configPromise;
}
configPromise = (async () => {
try {
const response = await fetch("/aws-exports.json");
if (!response.ok) {
throw new Error(`Failed to load aws-exports.json: ${response.status}`);
}
const config = (await response.json()) as AwsExportsConfig;
configCache = config;
return config;
} catch (error) {
console.error("Failed to load aws-exports.json:", error);
throw error;
}
})();
return configPromise;
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,14 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from "react";
import ReactDOM from "react-dom/client";
import "@copilotkit/react-core/v2/styles.css";
import App from "./App";
import "./styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -0,0 +1,25 @@
import CopilotChatInterface from "@/components/chat/CopilotKit";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { GlobalContextProvider } from "@/app/context/GlobalContext";
export default function ChatPage() {
const { isAuthenticated, signIn } = useAuth();
if (!isAuthenticated) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
<p className="text-4xl">Please sign in</p>
<Button onClick={() => signIn()}>Sign In</Button>
</div>
);
}
return (
<GlobalContextProvider>
<div className="relative h-screen">
<CopilotChatInterface />
</div>
</GlobalContextProvider>
);
}
@@ -0,0 +1,13 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { Routes, Route } from "react-router-dom";
import ChatPage from "./ChatPage";
export default function AppRoutes() {
return (
<Routes>
<Route path="/" element={<ChatPage />} />
</Routes>
);
}
@@ -0,0 +1,159 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-brand-dark: hsl(197, 37%, 24%);
--color-brand-teal: hsl(173, 58%, 39%);
--color-brand-lime: hsl(43, 74%, 66%);
--color-brand-yellow: hsl(27, 87%, 67%);
--color-brand-orange: hsl(12, 76%, 61%);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--brand-dark: 197 37% 24%;
--brand-teal: 173 58% 39%;
--brand-lime: 43 74% 66%;
--brand-yellow: 27 87% 67%;
--brand-orange: 12 76% 61%;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Font variables for Geist fonts */
--font-geist-sans:
"Geist Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--font-geist-mono: "Geist Mono", "Courier New", Consolas, Monaco, monospace;
--font-body: var(--font-geist-sans);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
a {
@apply text-brand-yellow hover:text-brand-yellow/80 underline;
}
}
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(30px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 2s ease-out;
}
@@ -0,0 +1,12 @@
import "react";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
[elemName: string]: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
}
}
}
@@ -0,0 +1,19 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_COGNITO_USER_POOL_ID?: string;
readonly VITE_COGNITO_CLIENT_ID?: string;
readonly VITE_COGNITO_REGION?: string;
readonly VITE_COGNITO_REDIRECT_URI?: string;
readonly VITE_COGNITO_POST_LOGOUT_REDIRECT_URI?: string;
readonly VITE_COGNITO_RESPONSE_TYPE?: string;
readonly VITE_COGNITO_SCOPE?: string;
readonly VITE_COGNITO_AUTOMATIC_SILENT_RENEW?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["src/app", "src/test"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,48 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
define: {
"import.meta.env.VITE_COPILOTKIT_THREADS_ENABLED": JSON.stringify(
process.env.VITE_COPILOTKIT_THREADS_ENABLED ??
(process.env.COPILOTKIT_LICENSE_TOKEN ? "true" : "false"),
),
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "build",
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
"react-vendor": ["react", "react-dom", "react-router-dom"],
"ui-vendor": [
"@radix-ui/react-dialog",
"@radix-ui/react-select",
"@radix-ui/react-alert-dialog",
"@radix-ui/react-progress",
],
"auth-vendor": ["react-oidc-context", "aws-amplify"],
},
},
},
},
server: {
port: 3000,
open: true,
},
});
@@ -0,0 +1,23 @@
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { FastMainStack } from "../lib/fast-main-stack";
import { ConfigManager } from "../lib/utils/config-manager";
// Load configuration using ConfigManager
const configManager = new ConfigManager("config.yaml");
// Initial props consist of configuration parameters
const props = configManager.getProps();
const app = new cdk.App();
// Deploy the new Amplify-based stack that solves the circular dependency
const amplifyStack = new FastMainStack(app, props.stack_name_base, {
config: props,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
app.synth();
@@ -0,0 +1,96 @@
{
"app": "npx ts-node --prefer-ts-exts bin/fast-cdk.ts",
"watch": {
"include": ["**"],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true,
"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true,
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws", "aws-cn"],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": false,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-route53-patters:useCertificate": true,
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
"@aws-cdk/aws-redshift:columnId": true,
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
"@aws-cdk/aws-kms:aliasNameRef": true,
"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true,
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
"@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
"@aws-cdk/aws-efs:denyAnonymousAccess": true,
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
"@aws-cdk/aws-eks:nodegroupNameAttribute": true,
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
"@aws-cdk/core:explicitStackTags": true,
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true,
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true,
"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true,
"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true,
"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true,
"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true,
"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true,
"@aws-cdk/core:enableAdditionalMetadataCollection": true,
"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false,
"@aws-cdk/aws-s3:setUniqueReplicationRoleName": true,
"@aws-cdk/aws-events:requireEventBusPolicySid": true,
"@aws-cdk/core:aspectPrioritiesMutating": true,
"@aws-cdk/aws-dynamodb:retainTableReplica": true,
"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true,
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": true
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
{
"name": "copilotkit-runtime-lambda",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@ag-ui/mcp-apps-middleware": "^0.0.3",
"@copilotkit/runtime": "1.62.2",
"@hono/node-server": "^1.13.7",
"hono": "^4.11.4",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/node": "^22.15.32",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,8 @@
import { streamHandle } from "hono/aws-lambda";
import { buildApp } from "./runtime";
const app = buildApp();
export const handler: (...args: unknown[]) => unknown = streamHandle(app) as (
...args: unknown[]
) => unknown;
@@ -0,0 +1,150 @@
/**
* Shared CopilotKit runtime — the single source of truth.
* Imported by index.ts (Lambda) and server.ts (local dev).
*/
import { EventType, HttpAgent } from "@ag-ui/client";
import type { BaseEvent } from "@ag-ui/client";
import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware";
import {
CopilotKitIntelligence,
CopilotRuntime,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { concatMap, of } from "rxjs";
import { randomUUID } from "node:crypto";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} environment variable is required`);
return value;
}
export function buildAgents(): Record<string, HttpAgent> {
const agentUrl = requireEnv("AGENTCORE_AG_UI_URL");
const agentName = process.env.COPILOTKIT_AGENT_NAME ?? "default";
const mcpServerUrl =
process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com";
const agent = new HttpAgent({ url: agentUrl, headers: {} });
agent.use(
new MCPAppsMiddleware({
mcpServers: [
{ type: "http", url: mcpServerUrl, serverId: "example_mcp_app" },
],
}),
);
return { [agentName]: agent };
}
/**
* AgentCore stores conversation history in its own memory layer (AgentCoreMemorySaver /
* AgentCoreMemorySessionManager). When CopilotKit reconnects to an existing thread
* (e.g. page refresh), it calls `connect()` which replays that stored history as a
* MESSAGES_SNAPSHOT event. Two issues arise from this that this runner fixes:
*
* 1. Unknown threads — CopilotKit may call `connect()` for a thread it has never
* `run()` against (e.g. on first load). The base runner would error; instead we
* return an empty snapshot so the UI initialises cleanly.
*
* 2. Missing tool-call results — AgentCore's snapshot includes assistant messages
* with tool calls, but the corresponding TOOL_CALL_RESULT events are absent.
* CopilotKit needs those results to reconcile its internal message state. We
* synthesise empty results for every past tool call before emitting the snapshot.
*/
export class AgentCoreRunner extends InMemoryAgentRunner {
private readonly knownThreadIds = new Set<string>();
override run(
request: Parameters<InMemoryAgentRunner["run"]>[0],
): ReturnType<InMemoryAgentRunner["run"]> {
if (request.threadId) this.knownThreadIds.add(request.threadId);
return super.run(request);
}
override connect(
request: Parameters<InMemoryAgentRunner["connect"]>[0],
): ReturnType<InMemoryAgentRunner["connect"]> {
if (!request.threadId || !this.knownThreadIds.has(request.threadId)) {
// Unknown thread — return an empty snapshot instead of erroring.
const runId =
typeof (request as { runId?: unknown }).runId === "string"
? ((request as { runId?: string }).runId ?? randomUUID())
: randomUUID();
return of(
{
type: EventType.RUN_STARTED,
threadId: request.threadId ?? randomUUID(),
runId,
} as BaseEvent,
{ type: EventType.MESSAGES_SNAPSHOT, messages: [] } as BaseEvent,
{
type: EventType.RUN_FINISHED,
threadId: request.threadId ?? randomUUID(),
runId,
} as BaseEvent,
) as unknown as ReturnType<InMemoryAgentRunner["connect"]>;
}
// Known thread — replay synthetic tool-call results before the snapshot so
// CopilotKit can reconcile its message state correctly.
return (super.connect(request) as any).pipe(
concatMap((event: any) => {
if (
event.type !== EventType.MESSAGES_SNAPSHOT ||
!("messages" in event)
)
return of(event);
const replayedResults = event.messages.flatMap((message: any) => {
if (message.role !== "assistant" || !message.toolCalls?.length)
return [];
return message.toolCalls.map(
(toolCall: any) =>
({
type: EventType.TOOL_CALL_RESULT,
toolCallId: toolCall.id,
messageId: `${toolCall.id}-result`,
content: "",
role: "tool",
}) satisfies BaseEvent,
);
});
return of(...replayedResults, event);
}),
) as ReturnType<InMemoryAgentRunner["connect"]>;
}
}
export function buildApp() {
const agents = buildAgents();
const agentName = process.env.COPILOTKIT_AGENT_NAME ?? "default";
const defaultAgent =
agents[agentName] ?? agents.default ?? Object.values(agents)[0];
if (!defaultAgent)
throw new Error("At least one CopilotKit agent URL must be configured");
const runtime = new CopilotRuntime({
agents: { ...agents, default: defaultAgent },
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your real auth-derived user identity before any
// multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new AgentCoreRunner() }),
// --- /copilotkit:intelligence ---
});
return createCopilotEndpoint({ runtime, basePath: "/copilotkit" });
}
@@ -0,0 +1,27 @@
// =============================================================================
// ██████████████████████████████████████████████████████████████████████████
// ██ ██
// ██ ⚠️ LOCAL DEVELOPMENT ONLY — DO NOT DEPLOY ⚠️ ██
// ██ ██
// ██ Serves the same CopilotKit app as index.ts (Lambda) but via ██
// ██ @hono/node-server instead of streamHandle. ██
// ██ ██
// ██ Set AGENTCORE_AG_UI_URL=http://agent:8080/invocations to point ██
// ██ at the local agent container instead of AWS. ██
// ██ ██
// ██ Production entrypoint: index.ts ██
// ██ ██
// ██████████████████████████████████████████████████████████████████████████
// =============================================================================
import { serve } from "@hono/node-server";
import { buildApp } from "./runtime";
const PORT = parseInt(process.env.PORT ?? "3001");
const app = buildApp();
serve({ fetch: app.fetch, port: PORT }, () => {
console.log(
`[local] CopilotKit bridge on :${PORT}${process.env.AGENTCORE_AG_UI_URL ?? "???"}`,
);
});
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,164 @@
"""
Custom Resource Lambda for managing OAuth2 Credential Provider lifecycle.
This Lambda is invoked by CloudFormation during stack deployment to manage
an OAuth2 Credential Provider in Bedrock AgentCore Identity. It retrieves the Cognito
client secret from Secrets Manager at runtime to avoid logging sensitive data.
CloudFormation Events:
- Create: Creates OAuth2 provider with credentials from Secrets Manager
- Update: Updates OAuth2 provider properties (clientId, clientSecret, discoveryUrl)
- Delete: Deletes OAuth2 provider by name
"""
import logging
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
bedrock_client = boto3.client("bedrock-agentcore-control")
secrets_client = boto3.client("secretsmanager")
def handler(event: dict, context: dict) -> dict:
"""
CloudFormation Custom Resource handler for OAuth2 Credential Provider.
Args:
event: CloudFormation event containing RequestType and ResourceProperties
context: Lambda context object
Returns:
Response dict with PhysicalResourceId and optional Data attributes
"""
request_type = event["RequestType"]
props = event["ResourceProperties"]
logger.info(f"Request type: {request_type}")
logger.info(f"Provider name: {props['ProviderName']}")
try:
if request_type == "Create":
return handle_create(props)
elif request_type == "Delete":
return handle_delete(event, props)
elif request_type == "Update":
return handle_update(event, props)
else:
raise ValueError(f"Unknown request type: {request_type}")
except Exception as e:
logger.error(f"Error handling {request_type}: {str(e)}", exc_info=True)
raise
def handle_create(props: dict) -> dict:
"""
Create OAuth2 Credential Provider.
Args:
props: ResourceProperties from CloudFormation event
Returns:
Response with PhysicalResourceId and provider ARN
"""
# Retrieve client secret from Secrets Manager (not logged)
secret_arn = props["ClientSecretArn"]
logger.info(f"Retrieving secret from: {secret_arn}")
secret_response = secrets_client.get_secret_value(SecretId=secret_arn)
client_secret = secret_response["SecretString"]
# Create OAuth2 Credential Provider
logger.info(f"Creating OAuth2 provider: {props['ProviderName']}")
response = bedrock_client.create_oauth2_credential_provider(
name=props["ProviderName"],
credentialProviderVendor="CustomOauth2",
oauth2ProviderConfigInput={
"customOauth2ProviderConfig": {
"clientId": props["ClientId"],
"clientSecret": client_secret,
"oauthDiscovery": {"discoveryUrl": props["DiscoveryUrl"]},
}
},
)
provider_arn = response["credentialProviderArn"]
logger.info(f"Created provider with ARN: {provider_arn}")
return {
"PhysicalResourceId": props["ProviderName"],
"Data": {"ProviderArn": provider_arn},
}
def handle_update(event: dict, props: dict) -> dict:
"""
Update OAuth2 Credential Provider.
Args:
event: CloudFormation event
props: ResourceProperties from CloudFormation event
Returns:
Response with PhysicalResourceId and provider ARN
"""
provider_name = event["PhysicalResourceId"]
logger.info(f"Updating OAuth2 provider: {provider_name}")
# Retrieve client secret from Secrets Manager
secret_arn = props["ClientSecretArn"]
logger.info(f"Retrieving secret from: {secret_arn}")
secret_response = secrets_client.get_secret_value(SecretId=secret_arn)
client_secret = secret_response["SecretString"]
# Update OAuth2 Credential Provider
response = bedrock_client.update_oauth2_credential_provider(
name=provider_name,
credentialProviderVendor="CustomOauth2",
oauth2ProviderConfigInput={
"customOauth2ProviderConfig": {
"clientId": props["ClientId"],
"clientSecret": client_secret,
"oauthDiscovery": {"discoveryUrl": props["DiscoveryUrl"]},
}
},
)
provider_arn = response["credentialProviderArn"]
logger.info(f"Updated provider with ARN: {provider_arn}")
return {
"PhysicalResourceId": provider_name,
"Data": {"ProviderArn": provider_arn},
}
def handle_delete(event: dict, props: dict) -> dict:
"""
Delete OAuth2 Credential Provider.
Args:
event: CloudFormation event
props: ResourceProperties from CloudFormation event
Returns:
Response with PhysicalResourceId
"""
provider_name = event["PhysicalResourceId"]
logger.info(f"Deleting OAuth2 provider: {provider_name}")
try:
bedrock_client.delete_oauth2_credential_provider(name=provider_name)
logger.info(f"Deleted provider: {provider_name}")
except bedrock_client.exceptions.ResourceNotFoundException:
logger.warning(f"Provider not found (already deleted): {provider_name}")
except Exception as e:
logger.error(f"Error deleting provider: {str(e)}")
raise
return {"PhysicalResourceId": provider_name}
@@ -0,0 +1,101 @@
import * as cdk from "aws-cdk-lib";
import * as amplify from "@aws-cdk/aws-amplify-alpha";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
export interface AmplifyStackProps extends cdk.NestedStackProps {
config: AppConfig;
}
export class AmplifyHostingStack extends cdk.NestedStack {
public readonly amplifyApp: amplify.App;
public readonly amplifyUrl: string;
public readonly stagingBucket: s3.Bucket;
constructor(scope: Construct, id: string, props: AmplifyStackProps) {
const description =
"Fullstack AgentCore Solution Template - Amplify Hosting Stack";
super(scope, id, { ...props, description });
// Create access logs bucket for staging bucket
const accessLogsBucket = new s3.Bucket(this, "StagingBucketAccessLogs", {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
publicReadAccess: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
lifecycleRules: [
{
id: "DeleteOldAccessLogs",
enabled: true,
expiration: cdk.Duration.days(90), // Keep access logs for 90 days
},
],
});
// Create staging bucket for Amplify deployments with dynamic name
this.stagingBucket = new s3.Bucket(this, "StagingBucket", {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
versioned: true, // Enable versioning as required by Amplify
publicReadAccess: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
serverAccessLogsBucket: accessLogsBucket,
serverAccessLogsPrefix: "staging-bucket-access-logs/",
lifecycleRules: [
{
id: "DeleteOldDeployments",
enabled: true,
expiration: cdk.Duration.days(30), // Clean up old deployment artifacts after 30 days
},
],
});
// Add bucket policy to allow Amplify service access
this.stagingBucket.addToResourcePolicy(
new iam.PolicyStatement({
sid: "AmplifyAccess",
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("amplify.amazonaws.com")],
actions: ["s3:GetObject", "s3:GetObjectVersion"],
resources: [this.stagingBucket.arnForObjects("*")],
}),
);
// Enforce SSL/TLS for all requests to the bucket
this.stagingBucket.addToResourcePolicy(
new iam.PolicyStatement({
sid: "DenyInsecureConnections",
effect: iam.Effect.DENY,
principals: [new iam.AnyPrincipal()],
actions: ["s3:*"],
resources: [
this.stagingBucket.bucketArn,
this.stagingBucket.arnForObjects("*"),
],
conditions: {
Bool: {
"aws:SecureTransport": "false",
},
},
}),
);
// Create the Amplify app
this.amplifyApp = new amplify.App(this, "AmplifyApp", {
appName: `${props.config.stack_name_base}-frontend`,
description: `${props.config.stack_name_base} - React Frontend`,
platform: amplify.Platform.WEB,
});
// Create main branch for the Amplify app
this.amplifyApp.addBranch("main", {
stage: "PRODUCTION",
branchName: "main",
});
// The predictable domain format: https://main.{appId}.amplifyapp.com
this.amplifyUrl = `https://main.${this.amplifyApp.appId}.amplifyapp.com`;
}
}
@@ -0,0 +1,869 @@
import * as cdk from "aws-cdk-lib";
import * as cognito from "aws-cdk-lib/aws-cognito";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ssm from "aws-cdk-lib/aws-ssm";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as logs from "aws-cdk-lib/aws-logs";
import * as agentcore from "@aws-cdk/aws-bedrock-agentcore-alpha";
import * as bedrockagentcore from "aws-cdk-lib/aws-bedrockagentcore";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as ecr_assets from "aws-cdk-lib/aws-ecr-assets";
import * as cr from "aws-cdk-lib/custom-resources";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
import { AgentCoreRole } from "./utils/agentcore-role";
import * as path from "path";
import * as fs from "fs";
import { execSync } from "child_process";
export interface BackendStackProps extends cdk.NestedStackProps {
config: AppConfig;
userPoolId: string;
userPoolClientId: string;
userPoolDomain: cognito.UserPoolDomain;
frontendUrl: string;
}
export class BackendStack extends cdk.NestedStack {
public readonly userPoolId: string;
public readonly userPoolClientId: string;
public readonly userPoolDomain: cognito.UserPoolDomain;
public copilotKitRuntimeUrl: string;
public runtimeArn: string;
public memoryArn: string;
private agentName: cdk.CfnParameter;
private userPool: cognito.IUserPool;
private machineClient: cognito.UserPoolClient;
private machineClientSecret: secretsmanager.Secret;
private runtimeCredentialProvider: cdk.CustomResource;
private agentRuntime: agentcore.Runtime;
constructor(scope: Construct, id: string, props: BackendStackProps) {
super(scope, id, props);
// Store the Cognito values
this.userPoolId = props.userPoolId;
this.userPoolClientId = props.userPoolClientId;
this.userPoolDomain = props.userPoolDomain;
// Import the Cognito resources from the other stack
this.userPool = cognito.UserPool.fromUserPoolId(
this,
"ImportedUserPoolForBackend",
props.userPoolId,
);
// then create the user pool client
cognito.UserPoolClient.fromUserPoolClientId(
this,
"ImportedUserPoolClient",
props.userPoolClientId,
);
// Create Machine-to-Machine authentication components
this.createMachineAuthentication(props.config);
// DEPLOYMENT ORDER EXPLANATION:
// 1. Cognito User Pool & Client (created in separate CognitoStack)
// 2. Machine Client & Resource Server (created above for M2M auth)
// 3. AgentCore Gateway (created next - uses machine client for auth)
// 4. AgentCore Runtime (created last - independent of gateway)
//
// This order ensures that authentication components are available before
// the gateway that depends on them, while keeping the runtime separate
// since it doesn't directly depend on the gateway.
// Create AgentCore Gateway (before Runtime)
this.createAgentCoreGateway(props.config);
// Create AgentCore Runtime resources
this.createAgentCoreRuntime(props.config);
// Store runtime ARN in SSM for frontend stack
this.createRuntimeSSMParameters(props.config);
// Store Cognito configuration in SSM for testing and frontend
this.createCognitoSSMParameters(props.config);
// Create standalone CopilotKit runtime API.
this.createCopilotKitRuntimeApi(props.config, props.frontendUrl);
}
private createAgentCoreRuntime(config: AppConfig): void {
const pattern = config.backend?.pattern || "strands-single-agent";
// Parameters
this.agentName = new cdk.CfnParameter(this, "AgentName", {
type: "String",
default: "FASTAgent",
description: "Name for the agent runtime",
});
const stack = cdk.Stack.of(this);
// Create the agent runtime artifact based on deployment type
let agentRuntimeArtifact: agentcore.AgentRuntimeArtifact;
// DOCKER DEPLOYMENT: Use container-based deployment
agentRuntimeArtifact = agentcore.AgentRuntimeArtifact.fromAsset(
path.resolve(__dirname, "..", ".."),
{
platform: ecr_assets.Platform.LINUX_ARM64,
file: `agents/${pattern}/Dockerfile`,
},
);
// Configure network mode based on config.yaml settings.
// PUBLIC: Runtime is accessible over the public internet (default).
// VPC: Runtime is deployed into a user-provided VPC for private network isolation.
// The user must ensure their VPC has the necessary VPC endpoints for AWS services.
// See docs/DEPLOYMENT.md for the full list of required VPC endpoints.
const networkConfiguration = this.buildNetworkConfiguration(config);
// Configure JWT authorizer with Cognito
const authorizerConfiguration =
agentcore.RuntimeAuthorizerConfiguration.usingJWT(
`https://cognito-idp.${stack.region}.amazonaws.com/${this.userPoolId}/.well-known/openid-configuration`,
[this.userPoolClientId],
);
// Create AgentCore execution role
const agentRole = new AgentCoreRole(this, "AgentCoreRole");
// Create memory resource with short-term memory (conversation history) as default
// To enable long-term strategies (summaries, preferences, facts), see docs/MEMORY_INTEGRATION.md
const memory = new cdk.CfnResource(this, "AgentMemory", {
type: "AWS::BedrockAgentCore::Memory",
properties: {
Name: cdk.Names.uniqueResourceName(this, { maxLength: 48 }),
EventExpiryDuration: 30,
Description: `Short-term memory for ${config.stack_name_base} agent`,
MemoryStrategies: [], // Empty array = short-term only (conversation history)
MemoryExecutionRoleArn: agentRole.roleArn,
Tags: {
Name: `${config.stack_name_base}_Memory`,
ManagedBy: "CDK",
},
},
});
const memoryId = memory.getAtt("MemoryId").toString();
const memoryArn = memory.getAtt("MemoryArn").toString();
// Store the memory ARN for access from main stack
this.memoryArn = memoryArn;
// Add memory-specific permissions to agent role
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "MemoryResourceAccess",
effect: iam.Effect.ALLOW,
actions: [
"bedrock-agentcore:CreateEvent",
"bedrock-agentcore:GetEvent",
"bedrock-agentcore:ListEvents",
"bedrock-agentcore:RetrieveMemoryRecords", // Only needed for long-term strategies
],
resources: [memoryArn],
}),
);
// Add SSM permissions for AgentCore Gateway URL lookup
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "SSMParameterAccess",
effect: iam.Effect.ALLOW,
actions: ["ssm:GetParameter", "ssm:GetParameters"],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/${config.stack_name_base}/*`,
],
}),
);
// Add Code Interpreter permissions
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "CodeInterpreterAccess",
effect: iam.Effect.ALLOW,
actions: [
"bedrock-agentcore:StartCodeInterpreterSession",
"bedrock-agentcore:StopCodeInterpreterSession",
"bedrock-agentcore:InvokeCodeInterpreter",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:aws:code-interpreter/*`,
],
}),
);
// Add OAuth2 Credential Provider access for AgentCore Runtime
// The @requires_access_token decorator performs a two-stage process:
// 1. GetOauth2CredentialProvider - Looks up provider metadata (ARN, vendor config, grant types)
// 2. GetResourceOauth2Token - Uses metadata to fetch the actual access token from Token Vault
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "OAuth2CredentialProviderAccess",
effect: iam.Effect.ALLOW,
actions: [
"bedrock-agentcore:GetOauth2CredentialProvider",
"bedrock-agentcore:GetResourceOauth2Token",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:oauth2-credential-provider/*`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/*`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/*`,
],
}),
);
// Add Secrets Manager access for OAuth2
// AgentCore Runtime needs to read two secrets:
// 1. Machine client secret (created by CDK)
// 2. Token Vault OAuth2 secret (created by AgentCore Identity)
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "SecretsManagerOAuth2Access",
effect: iam.Effect.ALLOW,
actions: ["secretsmanager:GetSecretValue"],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:/${config.stack_name_base}/machine_client_secret*`,
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:bedrock-agentcore-identity!default/oauth2/${config.stack_name_base}-runtime-gateway-auth*`,
],
}),
);
// Environment variables for the runtime
const envVars: { [key: string]: string } = {
AWS_REGION: stack.region,
AWS_DEFAULT_REGION: stack.region,
MEMORY_ID: memoryId,
STACK_NAME: config.stack_name_base,
GATEWAY_CREDENTIAL_PROVIDER_NAME: `${config.stack_name_base}-runtime-gateway-auth`, // Used by @requires_access_token decorator to look up the correct provider
};
// Add claude-agent-sdk specific environment variable
if (
pattern === "claude-agent-sdk-single-agent" ||
pattern === "claude-agent-sdk-multi-agent"
) {
envVars["CLAUDE_CODE_USE_BEDROCK"] = "1";
}
// Enable AG-UI / CopilotKit protocol for LangGraph and Strands agents
if (
pattern === "langgraph-single-agent" ||
pattern === "strands-single-agent"
) {
envVars["AGUI_ENABLED"] = "true";
}
// Create the runtime using L2 construct
// requestHeaderConfiguration allows the agent to read the Authorization header
// from RequestContext.request_headers, which is needed to securely extract the
// user ID from the validated JWT token (sub claim) instead of trusting the payload body.
this.agentRuntime = new agentcore.Runtime(this, "Runtime", {
runtimeName: `${config.stack_name_base.replace(/-/g, "_")}_${this.agentName.valueAsString}`,
agentRuntimeArtifact: agentRuntimeArtifact,
executionRole: agentRole,
networkConfiguration: networkConfiguration,
protocolConfiguration: agentcore.ProtocolType.HTTP,
environmentVariables: envVars,
authorizerConfiguration: authorizerConfiguration,
requestHeaderConfiguration: {
allowlistedHeaders: ["Authorization"],
},
description: `${pattern} agent runtime for ${config.stack_name_base}`,
});
// Store the runtime ARN
this.runtimeArn = this.agentRuntime.agentRuntimeArn;
// Outputs
new cdk.CfnOutput(this, "AgentRuntimeId", {
description: "ID of the created agent runtime",
value: this.agentRuntime.agentRuntimeId,
});
new cdk.CfnOutput(this, "AgentRuntimeArn", {
description: "ARN of the created agent runtime",
value: this.agentRuntime.agentRuntimeArn,
exportName: `${config.stack_name_base}-AgentRuntimeArn`,
});
new cdk.CfnOutput(this, "AgentRoleArn", {
description: "ARN of the agent execution role",
value: agentRole.roleArn,
});
// Memory ARN output
new cdk.CfnOutput(this, "MemoryArn", {
description: "ARN of the agent memory resource",
value: memoryArn,
});
}
private createRuntimeSSMParameters(config: AppConfig): void {
// Store runtime ARN in SSM for frontend stack
new ssm.StringParameter(this, "RuntimeArnParam", {
parameterName: `/${config.stack_name_base}/runtime-arn`,
stringValue: this.runtimeArn,
});
}
private createCognitoSSMParameters(config: AppConfig): void {
// Store Cognito configuration in SSM for testing and frontend access
new ssm.StringParameter(this, "CognitoUserPoolIdParam", {
parameterName: `/${config.stack_name_base}/cognito-user-pool-id`,
stringValue: this.userPoolId,
description: "Cognito User Pool ID",
});
new ssm.StringParameter(this, "CognitoUserPoolClientIdParam", {
parameterName: `/${config.stack_name_base}/cognito-user-pool-client-id`,
stringValue: this.userPoolClientId,
description: "Cognito User Pool Client ID",
});
new ssm.StringParameter(this, "MachineClientIdParam", {
parameterName: `/${config.stack_name_base}/machine_client_id`,
stringValue: this.machineClient.userPoolClientId,
description: "Machine Client ID for M2M authentication",
});
// Use the correct Cognito domain format from the passed domain
new ssm.StringParameter(this, "CognitoDomainParam", {
parameterName: `/${config.stack_name_base}/cognito_provider`,
stringValue: `${this.userPoolDomain.domainName}.auth.${cdk.Aws.REGION}.amazoncognito.com`,
description: "Cognito domain URL for token endpoint",
});
}
private createCopilotKitRuntimeApi(
config: AppConfig,
frontendUrl: string,
): void {
const buildAgentCoreAgUiUrl = (runtimeArn: string): string => {
const encodedRuntimeArn = cdk.Fn.join(
"%2F",
cdk.Fn.split("/", cdk.Fn.join("%3A", cdk.Fn.split(":", runtimeArn))),
);
return cdk.Fn.join("", [
"https://bedrock-agentcore.",
cdk.Stack.of(this).region,
".amazonaws.com/runtimes/",
encodedRuntimeArn,
"/invocations?qualifier=DEFAULT",
]);
};
const agentCoreAgUiUrl = buildAgentCoreAgUiUrl(this.runtimeArn);
const copilotKitRuntimeLambda = new lambda.Function(
this,
"CopilotKitRuntimeLambda",
{
functionName: `${config.stack_name_base}-copilotkit-runtime`,
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
handler: "dist/index.handler",
code: lambda.Code.fromAsset(
path.join(__dirname, "..", "lambdas", "copilotkit-runtime"),
{
assetHashType: cdk.AssetHashType.OUTPUT,
bundling: {
local: {
tryBundle(outputDir: string) {
const runtimeDir = path.join(
__dirname,
"..",
"lambdas",
"copilotkit-runtime",
);
execSync("npm ci --no-audit --no-fund", {
cwd: runtimeDir,
stdio: "inherit",
});
execSync("npm run build", {
cwd: runtimeDir,
stdio: "inherit",
});
execSync("npm prune --omit=dev", {
cwd: runtimeDir,
stdio: "inherit",
});
execSync(
`cp -R dist node_modules package.json package-lock.json ${outputDir}/`,
{
cwd: runtimeDir,
stdio: "inherit",
},
);
return true;
},
},
image: lambda.Runtime.NODEJS_20_X.bundlingImage,
environment: {
NPM_CONFIG_CACHE: "/tmp/.npm",
NPM_CONFIG_FETCH_RETRIES: "5",
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000",
},
command: [
"bash",
"-c",
[
"mkdir -p /tmp/.npm",
"npm ci --no-audit --no-fund",
"npm run build",
"npm prune --omit=dev",
"cp -R dist node_modules package.json package-lock.json /asset-output/",
].join(" && "),
],
},
},
),
environment: {
AGENTCORE_AG_UI_URL: agentCoreAgUiUrl,
COPILOTKIT_AGENT_NAME:
config.backend?.pattern || "langgraph-single-agent",
},
timeout: cdk.Duration.seconds(30),
memorySize: 1024,
logGroup: new logs.LogGroup(this, "CopilotKitRuntimeLambdaLogGroup", {
logGroupName: `/aws/lambda/${config.stack_name_base}-copilotkit-runtime`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
},
);
const copilotKitApi = new apigateway.RestApi(this, "CopilotKitRuntimeApi", {
restApiName: `${config.stack_name_base}-copilotkit-runtime-api`,
description: "Standalone CopilotKit runtime API backed by Lambda",
defaultCorsPreflightOptions: {
allowOrigins: [frontendUrl, "http://localhost:3000"],
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
},
deployOptions: {
stageName: "prod",
},
});
const runtimeIntegration = new apigateway.LambdaIntegration(
copilotKitRuntimeLambda,
{
responseTransferMode: apigateway.ResponseTransferMode.STREAM,
},
);
const runtimeResource = copilotKitApi.root.addResource("copilotkit");
runtimeResource.addMethod("GET", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
runtimeResource.addMethod("POST", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
const runtimeProxy = runtimeResource.addResource("{proxy+}");
runtimeProxy.addMethod("GET", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
runtimeProxy.addMethod("POST", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
this.copilotKitRuntimeUrl = copilotKitApi.urlForPath("/copilotkit");
new ssm.StringParameter(this, "CopilotKitRuntimeUrlParam", {
parameterName: `/${config.stack_name_base}/copilotkit-runtime-url`,
stringValue: this.copilotKitRuntimeUrl,
description: "CopilotKit runtime API URL",
});
new cdk.CfnOutput(this, "CopilotKitRuntimeUrl", {
description: "CopilotKit runtime API URL",
value: this.copilotKitRuntimeUrl,
});
}
private createAgentCoreGateway(config: AppConfig): void {
// Create comprehensive IAM role for gateway
const gatewayRole = new iam.Role(this, "GatewayRole", {
assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
description: "Role for AgentCore Gateway with comprehensive permissions",
});
// Bedrock permissions (region-agnostic)
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
],
resources: [
"arn:aws:bedrock:*::foundation-model/*",
`arn:aws:bedrock:*:${this.account}:inference-profile/*`,
],
}),
);
// SSM parameter access
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["ssm:GetParameter", "ssm:GetParameters"],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/${config.stack_name_base}/*`,
],
}),
);
// Cognito permissions
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"cognito-idp:DescribeUserPoolClient",
"cognito-idp:InitiateAuth",
],
resources: [this.userPool.userPoolArn],
}),
);
// CloudWatch Logs
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
resources: [
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/*`,
],
}),
);
// Cognito OAuth2 configuration for gateway
const cognitoIssuer = `https://cognito-idp.${this.region}.amazonaws.com/${this.userPool.userPoolId}`;
const cognitoDiscoveryUrl = `${cognitoIssuer}/.well-known/openid-configuration`;
// Create OAuth2 Credential Provider for AgentCore Runtime to authenticate with AgentCore Gateway
// Uses cr.Provider pattern with explicit Lambda to avoid logging secrets in CloudWatch
const providerName = `${config.stack_name_base}-runtime-gateway-auth`;
// Lambda to create/delete OAuth2 provider
const oauth2ProviderLambda = new lambda.Function(
this,
"OAuth2ProviderLambda",
{
runtime: lambda.Runtime.PYTHON_3_13,
handler: "index.handler",
code: lambda.Code.fromAsset(
path.join(__dirname, "..", "lambdas", "oauth2-provider"),
),
timeout: cdk.Duration.minutes(5),
logGroup: new logs.LogGroup(this, "OAuth2ProviderLambdaLogGroup", {
logGroupName: `/aws/lambda/${config.stack_name_base}-oauth2-provider`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
},
);
// Grant Lambda permissions to read machine client secret
this.machineClientSecret.grantRead(oauth2ProviderLambda);
// Grant Lambda permissions for Bedrock AgentCore operations
// OAuth2 Credential Provider operations - scoped to all providers in default Token Vault
// Note: Need both vault-level and nested resource permissions because:
// - CreateOauth2CredentialProvider checks permission on vault itself (token-vault/default)
// - Also checks permission on the nested resource path (token-vault/default/oauth2credentialprovider/*)
oauth2ProviderLambda.addToRolePolicy(
new iam.PolicyStatement({
actions: [
"bedrock-agentcore:CreateOauth2CredentialProvider",
"bedrock-agentcore:DeleteOauth2CredentialProvider",
"bedrock-agentcore:GetOauth2CredentialProvider",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default/oauth2credentialprovider/*`,
],
}),
);
// Token Vault operations - scoped to default vault
// Note: Need both exact match (default) and wildcard (default/*) because:
// - AWS checks permission on the vault container itself (token-vault/default)
// - AWS also checks permission on resources inside (token-vault/default/*)
oauth2ProviderLambda.addToRolePolicy(
new iam.PolicyStatement({
actions: [
"bedrock-agentcore:CreateTokenVault",
"bedrock-agentcore:GetTokenVault",
"bedrock-agentcore:DeleteTokenVault",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default/*`,
],
}),
);
// Grant Lambda permissions for Token Vault secret management
// Scoped to OAuth2 secrets in AgentCore Identity default namespace
oauth2ProviderLambda.addToRolePolicy(
new iam.PolicyStatement({
actions: [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:PutSecretValue",
],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:bedrock-agentcore-identity!default/oauth2/*`,
],
}),
);
// Create Custom Resource Provider
const oauth2Provider = new cr.Provider(this, "OAuth2ProviderProvider", {
onEventHandler: oauth2ProviderLambda,
});
// Create Custom Resource
const runtimeCredentialProvider = new cdk.CustomResource(
this,
"RuntimeCredentialProvider",
{
serviceToken: oauth2Provider.serviceToken,
properties: {
ProviderName: providerName,
ClientSecretArn: this.machineClientSecret.secretArn,
DiscoveryUrl: cognitoDiscoveryUrl,
ClientId: this.machineClient.userPoolClientId,
},
},
);
// Store for use in createAgentCoreRuntime()
this.runtimeCredentialProvider = runtimeCredentialProvider;
// Create Gateway using L1 construct (CfnGateway)
// This replaces the Custom Resource approach with native CloudFormation support
const gateway = new bedrockagentcore.CfnGateway(this, "AgentCoreGateway", {
name: `${config.stack_name_base}-gateway`,
roleArn: gatewayRole.roleArn,
protocolType: "MCP",
protocolConfiguration: {
mcp: {
supportedVersions: ["2025-03-26"],
// Optional: Enable semantic search for tools
// searchType: "SEMANTIC",
},
},
authorizerType: "CUSTOM_JWT",
authorizerConfiguration: {
customJwtAuthorizer: {
allowedClients: [this.machineClient.userPoolClientId],
discoveryUrl: cognitoDiscoveryUrl,
},
},
description: "AgentCore Gateway with MCP protocol and JWT authentication",
});
// Ensure proper creation order
gateway.node.addDependency(this.machineClient);
gateway.node.addDependency(gatewayRole);
// Store AgentCore Gateway URL in SSM for AgentCore Runtime access
new ssm.StringParameter(this, "GatewayUrlParam", {
parameterName: `/${config.stack_name_base}/gateway_url`,
stringValue: gateway.attrGatewayUrl,
description: "AgentCore Gateway URL",
});
// Output gateway information
new cdk.CfnOutput(this, "GatewayId", {
value: gateway.attrGatewayIdentifier,
description: "AgentCore Gateway ID",
});
new cdk.CfnOutput(this, "GatewayUrl", {
value: gateway.attrGatewayUrl,
description: "AgentCore Gateway URL",
});
new cdk.CfnOutput(this, "GatewayArn", {
value: gateway.attrGatewayArn,
description: "AgentCore Gateway ARN",
});
new cdk.CfnOutput(this, "GatewayTargetId", {
value: gatewayTarget.ref,
description: "AgentCore Gateway Target ID",
});
new cdk.CfnOutput(this, "ToolLambdaArn", {
description: "ARN of the sample tool Lambda",
value: toolLambda.functionArn,
});
}
private createMachineAuthentication(config: AppConfig): void {
// Create Resource Server for Machine-to-Machine (M2M) authentication
// This defines the API scopes that machine clients can request access to
const resourceServer = new cognito.UserPoolResourceServer(
this,
"ResourceServer",
{
userPool: this.userPool,
identifier: `${config.stack_name_base}-gateway`,
userPoolResourceServerName: `${config.stack_name_base}-gateway-resource-server`,
scopes: [
new cognito.ResourceServerScope({
scopeName: "read",
scopeDescription: "Read access to gateway",
}),
new cognito.ResourceServerScope({
scopeName: "write",
scopeDescription: "Write access to gateway",
}),
],
},
);
// Create Machine Client for AgentCore Gateway authentication
//
// WHAT IS A MACHINE CLIENT?
// A machine client is a Cognito User Pool Client configured for server-to-server authentication
// using the OAuth2 Client Credentials flow. Unlike user-facing clients, it doesn't require
// human interaction or user credentials.
//
// HOW IS IT DIFFERENT FROM THE REGULAR USER POOL CLIENT?
// - Regular client: Uses Authorization Code flow for human users (frontend login)
// - Machine client: Uses Client Credentials flow for service-to-service authentication
// - Regular client: No client secret (public client for frontend security)
// - Machine client: Has client secret (confidential client for backend security)
// - Regular client: Scopes are openid, email, profile (user identity)
// - Machine client: Scopes are custom resource server scopes (API permissions)
//
// WHY IS IT NEEDED?
// The AgentCore Gateway needs to authenticate with Cognito to validate tokens and make
// API calls on behalf of the system. The machine client provides the credentials for
// this service-to-service authentication without requiring user interaction.
this.machineClient = new cognito.UserPoolClient(this, "MachineClient", {
userPool: this.userPool,
userPoolClientName: `${config.stack_name_base}-machine-client`,
generateSecret: true, // Required for client credentials flow
oAuth: {
flows: {
clientCredentials: true, // Enable OAuth2 Client Credentials flow
},
scopes: [
// Grant access to the resource server scopes defined above
cognito.OAuthScope.resourceServer(
resourceServer,
new cognito.ResourceServerScope({
scopeName: "read",
scopeDescription: "Read access to gateway",
}),
),
cognito.OAuthScope.resourceServer(
resourceServer,
new cognito.ResourceServerScope({
scopeName: "write",
scopeDescription: "Write access to gateway",
}),
),
],
},
});
// Machine client must be created after resource server
this.machineClient.node.addDependency(resourceServer);
// Store machine client secret in Secrets Manager for testing and external access.
// This secret is used by test scripts and potentially other external tools.
this.machineClientSecret = new secretsmanager.Secret(
this,
"MachineClientSecret",
{
secretName: `/${config.stack_name_base}/machine_client_secret`,
secretStringValue: cdk.SecretValue.unsafePlainText(
this.machineClient.userPoolClientSecret.unsafeUnwrap(),
),
description: "Machine Client Secret for M2M authentication",
},
);
}
/**
* Builds the RuntimeNetworkConfiguration based on the config.yaml settings.
* When network_mode is "VPC", imports the user's existing VPC, subnets, and
* optionally security groups, then returns a VPC-based network configuration.
* When network_mode is "PUBLIC" (default), returns a public network configuration.
*
* @param config - The application configuration from config.yaml.
* @returns A RuntimeNetworkConfiguration for the AgentCore Runtime.
*/
private buildNetworkConfiguration(
config: AppConfig,
): agentcore.RuntimeNetworkConfiguration {
if (config.backend.network_mode === "VPC") {
const vpcConfig = config.backend.vpc;
// vpc config is validated in ConfigManager, but guard here for type safety
if (!vpcConfig) {
throw new Error(
"backend.vpc configuration is required when network_mode is 'VPC'.",
);
}
// Import the user's existing VPC by ID.
// This performs a context lookup at synth time to resolve VPC attributes.
const vpc = ec2.Vpc.fromLookup(this, "ImportedVpc", {
vpcId: vpcConfig.vpc_id,
});
// Import the user-specified subnets by their IDs.
// These subnets must exist within the VPC specified above.
const subnets: ec2.ISubnet[] = vpcConfig.subnet_ids.map(
(subnetId: string, index: number) =>
ec2.Subnet.fromSubnetId(this, `ImportedSubnet${index}`, subnetId),
);
// Build the VPC config props for the AgentCore L2 construct.
// Security groups are optional — if not provided, the construct creates a default one.
const securityGroups =
vpcConfig.security_group_ids && vpcConfig.security_group_ids.length > 0
? vpcConfig.security_group_ids.map((sgId: string, index: number) =>
ec2.SecurityGroup.fromSecurityGroupId(
this,
`ImportedSG${index}`,
sgId,
),
)
: undefined;
const vpcConfigProps: agentcore.VpcConfigProps = {
vpc: vpc,
vpcSubnets: {
subnets: subnets,
},
securityGroups: securityGroups,
};
return agentcore.RuntimeNetworkConfiguration.usingVpc(
this,
vpcConfigProps,
);
}
// Default: public network mode
return agentcore.RuntimeNetworkConfiguration.usingPublicNetwork();
}
}
@@ -0,0 +1,143 @@
import * as cdk from "aws-cdk-lib";
import * as cognito from "aws-cdk-lib/aws-cognito";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
export interface CognitoStackProps extends cdk.NestedStackProps {
config: AppConfig;
callbackUrls?: string[];
}
export class CognitoStack extends cdk.NestedStack {
public userPoolId: string;
public userPoolClientId: string;
public userPoolDomain: cognito.UserPoolDomain;
constructor(scope: Construct, id: string, props: CognitoStackProps) {
super(scope, id, props);
this.createCognitoUserPool(props.config, props.callbackUrls);
}
private createCognitoUserPool(
config: AppConfig,
callbackUrls?: string[],
): void {
// Use provided callback URLs or defaults
const defaultCallbackUrls = [
"http://localhost:3000",
"https://localhost:3000",
];
const finalCallbackUrls = callbackUrls || defaultCallbackUrls;
const userPool = new cognito.UserPool(this, "UserPool", {
userPoolName: `${config.stack_name_base}-user-pool`,
selfSignUpEnabled: false,
signInAliases: {
email: true,
},
autoVerify: {
email: true,
},
standardAttributes: {
email: {
required: true,
mutable: false,
},
},
passwordPolicy: {
minLength: 8,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: cdk.RemovalPolicy.DESTROY,
userInvitation: {
emailSubject: `Welcome to ${config.stack_name_base}!`,
emailBody: `<p>Hello {username},</p>
<p>Welcome to ${config.stack_name_base}! Your username is <strong>{username}</strong> and your temporary password is: <strong>{####}</strong></p>
<p>Please use this temporary password to log in and set your permanent password.</p>
<p>The CloudFront URL to your application is stored as an output in the "${config.stack_name_base}" stack, and will be printed to your terminal once the deployment process completes.</p>
<p>Thanks,</p>
<p>Fullstack AgentCore Solution Template Team</p>`,
},
});
const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", {
userPool: userPool,
userPoolClientName: `${config.stack_name_base}-client`,
generateSecret: false,
authFlows: {
userPassword: true,
userSrp: true,
},
oAuth: {
flows: {
authorizationCodeGrant: true,
},
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
],
// Support both localhost development and production URLs
callbackUrls: finalCallbackUrls,
logoutUrls: finalCallbackUrls,
},
preventUserExistenceErrors: true,
});
this.userPoolDomain = new cognito.UserPoolDomain(this, "UserPoolDomain", {
userPool: userPool,
cognitoDomain: {
domainPrefix: `${config.stack_name_base.toLowerCase()}-${cdk.Aws.ACCOUNT_ID}-${
cdk.Aws.REGION
}`,
},
// Enable the newer managed login UI (v2) with the branding designer. Comment or remove this
// if you'd like to use the old classic UI.
managedLoginVersion: cognito.ManagedLoginVersion.NEWER_MANAGED_LOGIN,
});
// Create managed login branding with Cognito's default styles
// This is required for the v2 managed login to display properly
const managedLoginBranding = new cognito.CfnManagedLoginBranding(
this,
"ManagedLoginBranding",
{
userPoolId: userPool.userPoolId,
clientId: userPoolClient.userPoolClientId,
useCognitoProvidedValues: true,
},
);
managedLoginBranding.node.addDependency(this.userPoolDomain);
// Store the IDs for export
this.userPoolId = userPool.userPoolId;
this.userPoolClientId = userPoolClient.userPoolClientId;
// Create admin user if email is provided in config
if (config.admin_user_email) {
new cognito.CfnUserPoolUser(this, "AdminUser", {
userPoolId: userPool.userPoolId,
username: config.admin_user_email,
userAttributes: [
{
name: "email",
value: config.admin_user_email,
},
],
desiredDeliveryMediums: ["EMAIL"],
});
// Output admin user creation status
new cdk.CfnOutput(this, "AdminUserCreated", {
description: "Admin user created and credentials emailed",
value: `Admin user created: ${config.admin_user_email}`,
});
}
}
}
@@ -0,0 +1,105 @@
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
// Import nested stacks
import { BackendStack } from "./backend-stack";
import { AmplifyHostingStack } from "./amplify-hosting-stack";
import { CognitoStack } from "./cognito-stack";
export interface FastAmplifyStackProps extends cdk.StackProps {
config: AppConfig;
}
export class FastMainStack extends cdk.Stack {
public readonly amplifyHostingStack: AmplifyHostingStack;
public readonly backendStack: BackendStack;
public readonly cognitoStack: CognitoStack;
constructor(scope: Construct, id: string, props: FastAmplifyStackProps) {
const description =
"CopilotKit + AWS AgentCore Integration Example (uksb-v6dos0t5g8)";
super(scope, id, { ...props, description });
// Step 1: Create the Amplify stack to get the predictable domain
this.amplifyHostingStack = new AmplifyHostingStack(this, `${id}-amplify`, {
config: props.config,
});
this.cognitoStack = new CognitoStack(this, `${id}-cognito`, {
config: props.config,
callbackUrls: [
"http://localhost:3000",
this.amplifyHostingStack.amplifyUrl,
],
});
// Step 2: Create backend stack with the predictable Amplify URL and Cognito details
this.backendStack = new BackendStack(this, `${id}-backend`, {
config: props.config,
userPoolId: this.cognitoStack.userPoolId,
userPoolClientId: this.cognitoStack.userPoolClientId,
userPoolDomain: this.cognitoStack.userPoolDomain,
frontendUrl: this.amplifyHostingStack.amplifyUrl,
});
// Outputs
new cdk.CfnOutput(this, "AmplifyAppId", {
value: this.amplifyHostingStack.amplifyApp.appId,
description: "Amplify App ID - use this for manual deployment",
exportName: `${props.config.stack_name_base}-AmplifyAppId`,
});
new cdk.CfnOutput(this, "CognitoUserPoolId", {
value: this.cognitoStack.userPoolId,
description: "Cognito User Pool ID",
exportName: `${props.config.stack_name_base}-CognitoUserPoolId`,
});
new cdk.CfnOutput(this, "CognitoClientId", {
value: this.cognitoStack.userPoolClientId,
description: "Cognito User Pool Client ID",
exportName: `${props.config.stack_name_base}-CognitoClientId`,
});
new cdk.CfnOutput(this, "CognitoDomain", {
value: `${this.cognitoStack.userPoolDomain.domainName}.auth.${cdk.Aws.REGION}.amazoncognito.com`,
description: "Cognito Domain for OAuth",
exportName: `${props.config.stack_name_base}-CognitoDomain`,
});
new cdk.CfnOutput(this, "RuntimeArn", {
value: this.backendStack.runtimeArn,
description: "AgentCore Runtime ARN",
exportName: `${props.config.stack_name_base}-RuntimeArn`,
});
new cdk.CfnOutput(this, "MemoryArn", {
value: this.backendStack.memoryArn,
description: "AgentCore Memory ARN",
exportName: `${props.config.stack_name_base}-MemoryArn`,
});
new cdk.CfnOutput(this, "CopilotKitRuntimeUrl", {
value: this.backendStack.copilotKitRuntimeUrl,
description: "CopilotKit runtime API URL",
exportName: `${props.config.stack_name_base}-CopilotKitRuntimeUrl`,
});
new cdk.CfnOutput(this, "AmplifyConsoleUrl", {
value: `https://console.aws.amazon.com/amplify/apps/${this.amplifyHostingStack.amplifyApp.appId}`,
description: "Amplify Console URL for monitoring deployments",
});
new cdk.CfnOutput(this, "AmplifyUrl", {
value: this.amplifyHostingStack.amplifyUrl,
description: "Amplify Frontend URL (available after deployment)",
});
new cdk.CfnOutput(this, "StagingBucketName", {
value: this.amplifyHostingStack.stagingBucket.bucketName,
description: "S3 bucket for Amplify deployment staging",
exportName: `${props.config.stack_name_base}-StagingBucket`,
});
}
}

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