Merge branch 'main' into feat/reskinnable-demo-people-skin

This commit is contained in:
Maxim
2026-08-07 18:51:35 +02:00
committed by GitHub
8 changed files with 309 additions and 101 deletions
@@ -92,11 +92,30 @@ const intelligenceEnabled = Boolean(
*/
function agentIdFromUrl(url: string): string | undefined {
try {
const segments = new URL(url).pathname.split("/").filter(Boolean);
const parsed = new URL(url);
const segments = parsed.pathname.split("/").filter(Boolean);
const i = segments.lastIndexOf("agent");
if (i >= 0 && i + 1 < segments.length) {
return decodeURIComponent(segments[i + 1]!);
}
// THREAD ROUTES CARRY THE AGENT IN THE QUERY STRING, NOT THE PATH.
//
// Run/suggest/connect are `/agent/:agentId/...`, but the thread list is
// `/threads?agentId=<id>`. Reading only the path meant every thread-list
// request looked agentId-LESS and fell through to `defaultSkinId`'s
// resolver — i.e. banking's — so a non-default skin listed threads under
// banking's end-user id and got an empty array back. Its runs, which DO go
// through `/agent/:id/run`, resolved correctly, so threads were created
// under one identity and listed under another.
//
// The symptom is nasty precisely because nothing errors: the thread rail
// just says "No conversations yet" forever and a browser reload never
// restores the conversation, which reads as "this product doesn't persist
// threads" — the exact opposite of what the demo is trying to prove.
// Banking was immune only because it IS `defaultSkinId`; airline,
// logistics, keel and people were all affected.
const fromQuery = parsed.searchParams.get("agentId");
if (fromQuery) return fromQuery;
} catch {
// Malformed URL — treat as "no agentId" and fall back.
}
@@ -72,7 +72,27 @@ import React, { useState } from "react";
import type { CopilotModalProps } from "./Modal";
import { CopilotModal } from "./Modal";
export function CopilotSidebar(props: CopilotModalProps) {
export interface CopilotSidebarProps extends CopilotModalProps {
/**
* Make the sidebar's content wrapper exactly one viewport tall, so children
* can use `height: 100%` (or `flex: 1`) to fill the screen.
*
* Off by default: the wrappers are auto-height, so page content flows
* normally and percentage heights on children collapse to content height.
*
* ```tsx
* <CopilotSidebar fullHeightChildren>
* <div style={{ height: "100%" }}>...</div>
* </CopilotSidebar>
* ```
*/
fullHeightChildren?: boolean;
}
export function CopilotSidebar({
fullHeightChildren = false,
...props
}: CopilotSidebarProps) {
props = {
...props,
className: props.className
@@ -88,8 +108,16 @@ export function CopilotSidebar(props: CopilotModalProps) {
setExpandedClassName(open ? "sidebarExpanded" : "");
};
const contentWrapperClassName = [
"copilotKitSidebarContentWrapper",
expandedClassName,
fullHeightChildren ? "copilotKitSidebarFullHeightChildren" : "",
]
.filter(Boolean)
.join(" ");
return (
<div className={`copilotKitSidebarContentWrapper ${expandedClassName}`}>
<div className={contentWrapperClassName}>
<CopilotModal {...props} {...{ onSetOpen }}>
{props.children}
</CopilotModal>
@@ -1,6 +1,7 @@
export * from "./props";
export { CopilotPopup } from "./Popup";
export { CopilotSidebar } from "./Sidebar";
export type { CopilotSidebarProps } from "./Sidebar";
export { CopilotChat } from "./Chat";
export { CopilotModal } from "./Modal";
export type { CopilotModalProps } from "./Modal";
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
const sidebarCss = fs.readFileSync(
path.resolve(__dirname, "sidebar.css"),
"utf-8",
);
const sidebarTsx = fs.readFileSync(
path.resolve(__dirname, "../components/chat/Sidebar.tsx"),
"utf-8",
);
/**
* Regression guard for #261: children of `CopilotSidebar` could not use
* `height: 100%`, because both wrappers around them are auto-height blocks.
* The fix is opt-in, so the guard has two halves — the escape hatch works,
* and it stays off for everyone who didn't ask for it.
*/
describe("sidebar full-height children opt-in", () => {
const fullHeightRule =
/\.copilotKitSidebarContentWrapper\.copilotKitSidebarFullHeightChildren\s*\{([^}]*)\}/;
const childrenWrapperRule =
/\.copilotKitSidebarContentWrapper\.copilotKitSidebarFullHeightChildren\s*>\s*\.copilotKitModalChildrenWrapper\s*\{([^}]*)\}/;
it("gives the content wrapper a definite height children can resolve against", () => {
const [, body] = sidebarCss.match(fullHeightRule) ?? [];
expect(body).toBeDefined();
expect(body).toContain("display: flex");
expect(body).toContain("flex-direction: column");
// A viewport unit, not `100%` — `100%` would silently no-op in any app
// that doesn't declare a height on html/body/#root.
expect(body).toContain("height: 100dvh");
expect(body).toContain("height: 100vh");
expect(body).not.toMatch(/height:\s*100%/);
});
it("lets the children wrapper fill that height without a flex min-height floor", () => {
const [, body] = sidebarCss.match(childrenWrapperRule) ?? [];
expect(body).toBeDefined();
expect(body).toContain("flex: 1 1 auto");
expect(body).toContain("min-height: 0");
});
it("leaves the default content wrapper auto-height", () => {
const [, base] =
sidebarCss.match(/\.copilotKitSidebarContentWrapper\s*\{([^}]*)\}/) ?? [];
expect(base).toBeDefined();
expect(base).not.toContain("height");
expect(base).not.toContain("display");
});
it("applies the modifier class only when fullHeightChildren is set", () => {
expect(sidebarTsx).toContain("fullHeightChildren = false");
expect(sidebarTsx).toMatch(
/fullHeightChildren\s*\?\s*"copilotKitSidebarFullHeightChildren"\s*:\s*""/,
);
});
});
+27
View File
@@ -44,3 +44,30 @@
margin-right: 28rem;
}
}
/*
* Opt-in (via the `fullHeightChildren` prop on `CopilotSidebar`): let children
* of the sidebar use `height: 100%`.
*
* By default both wrappers CopilotSidebar puts around your app are auto-height
* blocks, so a percentage height on a child has no definite containing block to
* resolve against and collapses to content height.
*
* The viewport unit is deliberate: `height: 100%` here would only resolve if
* every ancestor (html/body/#root) also declared a height, which react-ui
* neither sets nor can guarantee. `min-height: 0` on the children wrapper
* overrides the flex-item `min-height: auto` default so tall content scrolls
* inside the child instead of stretching the wrapper past the viewport.
*/
.copilotKitSidebarContentWrapper.copilotKitSidebarFullHeightChildren {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
}
.copilotKitSidebarContentWrapper.copilotKitSidebarFullHeightChildren
> .copilotKitModalChildrenWrapper {
flex: 1 1 auto;
min-height: 0;
}
+65 -35
View File
@@ -81,29 +81,33 @@ export const GET = (req: NextRequest) => handler(req);
## Frontend
Pass your token via the `properties` prop. CopilotKit forwards it to LangGraph as a Bearer token automatically.
Pass your token via the `headers` prop. CopilotKit attaches it to every runtime request, and the runtime forwards the `Authorization` header on to your agent — whether that's a LangGraph deployment or a self-hosted AG-UI endpoint.
```tsx title="frontend/src/app/page.tsx"
import { CopilotKit } from "@copilotkit/react-core/v2";
<CopilotKit
runtimeUrl="/api/copilotkit"
properties={{
authorization: userToken,
headers={{
Authorization: `Bearer ${userToken}`,
}}
>
<YourApp />
</CopilotKit>
```
<Callout type="warning" title="`properties` is not an auth channel">
Older versions of this guide passed the token as `properties={{ authorization: userToken }}`. The runtime delivers `properties` to the agent as AG-UI `forwardedProps` — run payload data, not a request header — so nothing turns them into a Bearer credential. Use `headers` for auth. Headers the server configured on the agent itself still win on collision, so a service-to-service token can't be overridden from the browser.
</Callout>
## Backend
LangGraph supports two deployment modes. The frontend code above is the same in both, but the backend wiring differs in where the resolved user identity lands. Pick the tab that matches where your agent runs.
<Tabs items={['LangGraph Platform', 'Self-hosted']}>
<Tabs items={['LangGraph Platform', 'Self-hosted (FastAPI)']}>
<Tab value="LangGraph Platform">
On LangGraph Platform, authentication is a managed service. You declare an `@auth.authenticate` handler, and Platform runs it on every request before the graph starts. The handler returns a user object that becomes available to every node in the run.
On LangGraph Platform (and on `langgraph dev`), authentication is a managed service. You declare an `@auth.authenticate` handler, and the server runs it on every request before the graph starts. The forwarded `Authorization` header arrives as the handler's `authorization` argument, and the handler's return value becomes available to every node in the run.
```python title="backend/auth.py"
from langgraph_sdk import Auth
@@ -125,13 +129,13 @@ async def authenticate(authorization: str | None):
}
```
The return value of the handler shows up in every node's `config["configuration"]["langgraph_auth_user"]`. From there, scoping tool access or filtering data is straightforward:
The return value of the handler shows up in every node's `config["configurable"]["langgraph_auth_user"]`. From there, scoping tool access or filtering data is straightforward:
```python title="backend/agent.py"
from langchain_core.runnables import RunnableConfig
async def my_agent_node(state: AgentState, config: RunnableConfig):
user_info = config["configuration"]["langgraph_auth_user"]
user_info = config["configurable"]["langgraph_auth_user"]
user_id = user_info["identity"]
user_role = user_info.get("role")
# agent logic with user context
@@ -141,46 +145,72 @@ async def my_agent_node(state: AgentState, config: RunnableConfig):
For full handler details, see the [LangGraph Platform Authentication documentation](https://docs.langchain.com/langsmith/auth#authentication).
</Tab>
<Tab value="Self-hosted">
<Tab value="Self-hosted (FastAPI)">
When you self-host the agent, there's no managed auth handler to plug into. Instead, you forward the raw token onto every run by configuring the agent dynamically — the request's `properties.authorization` becomes part of `langgraph_config["configurable"]`, where every node can read it back later.
When you self-host the agent behind FastAPI, there's no managed auth handler to plug into — validation is your job, and the natural place for it is the endpoint that serves the AG-UI stream. `add_langgraph_fastapi_endpoint` mounts that endpoint for you, but it takes one pre-built agent and gives you no per-request hook, so replace it with the equivalent route of your own: a FastAPI dependency verifies the `Authorization` header the runtime forwarded, and the resolved user is baked into a per-request agent's `config`.
```python title="backend/demo.py"
from copilotkit import CopilotKitRemoteEndpoint, LangGraphAGUIAgent
```python title="backend/main.py"
from typing import Optional
sdk = CopilotKitRemoteEndpoint(
agents=lambda context: [
LangGraphAGUIAgent(
name="sample_agent",
description="Agent with authentication support",
graph=graph,
langgraph_config={
"configurable": {
"copilotkit_auth": context["properties"].get("authorization"),
},
},
),
],
)
from ag_ui.core.types import RunAgentInput
from ag_ui.encoder import EventEncoder
from copilotkit import LangGraphAGUIAgent
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from src.agent import graph
app = FastAPI()
def current_user(authorization: Optional[str] = Header(default=None)) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
return validate_your_token(authorization.removeprefix("Bearer ").strip()) # your validation
@app.post("/")
async def run_agent(
input_data: RunAgentInput,
request: Request,
user: dict = Depends(current_user),
):
encoder = EventEncoder(accept=request.headers.get("accept"))
# One agent per request: the verified identity rides on this run only, and
# each request gets its own isolated streaming state.
agent = LangGraphAGUIAgent(
name="sample_agent",
graph=graph,
config={"configurable": {"auth_user": user}},
)
async def event_generator():
async for event in agent.run(input_data):
yield encoder.encode(event)
return StreamingResponse(event_generator(), media_type=encoder.get_content_type())
```
Validation is your job in this mode. Inside any node, pull the token out of `config["configurable"]` and run it through your verifier. Decide the policy explicitly: reject unauthenticated calls, or fall through to an `anonymous` branch as the example below does.
Unauthenticated requests never reach the graph — they get a 401 from the dependency. Authenticated ones arrive with an already-verified user on the config, so nodes read identity instead of re-validating a raw token:
```python title="backend/agent.py"
```python title="backend/src/agent.py"
from langchain_core.runnables import RunnableConfig
async def my_agent_node(state: AgentState, config: RunnableConfig):
auth_token = config["configurable"].get("copilotkit_auth")
if auth_token:
user_info = validate_your_token(auth_token)
user_id = user_info["user_id"]
user_role = user_info.get("role")
else:
user_id = "anonymous"
user_role = None
user = config["configurable"]["auth_user"]
user_id = user["user_id"]
user_role = user.get("role")
# agent logic with user context
return state
```
<Callout type="info" title="If you only need the 401 gate">
When nodes don't need the identity — you just want unauthenticated traffic rejected — keep `add_langgraph_fastapi_endpoint` and hang the dependency off the app: `FastAPI(dependencies=[Depends(current_user)])`. The gate applies, but nothing lands on the run config, so `config["configurable"]` stays empty of user context.
</Callout>
<Callout type="warning" title="`CopilotKitRemoteEndpoint` no longer works here">
Guides written for CopilotKit v1 wrapped the graph in `CopilotKitRemoteEndpoint(agents=lambda context: [...])`. That path is retired: `copilotkit` no longer exports `LangGraphAgent` (`ImportError`), and the current `LangGraphAGUIAgent` exposes `run()` rather than the `execute()` that `CopilotKitRemoteEndpoint` calls — giving `AgentExecutionException: 'LangGraphAGUIAgent' object has no attribute 'execute'`. Use the endpoint above instead.
</Callout>
</Tab>
</Tabs>
@@ -7,46 +7,49 @@ description: "Secure your LangGraph agents with user authentication (Platform &
CopilotKit supports user authentication for LangGraph agents in two deployment modes:
- **LangGraph Platform**: Uses built-in authentication with `@auth.authenticate` decorator
- **Self-hosted**: Uses dynamic agent configuration to pass authentication context
- **LangGraph Platform** (and `langgraph dev`): the server runs your `@auth.authenticate` handler on every request
- **Self-hosted** (FastAPI + AG-UI): your endpoint validates the request and injects the resolved user into the run config
Both approaches enable your agents to access authenticated user context and implement proper authorization.
In both cases the frontend sends the same thing — an `Authorization` header — and the runtime forwards it to the agent.
## How It Works
```mermaid
sequenceDiagram
participant Frontend
participant CopilotKit
participant LangGraphAgent
participant Agent
participant Runtime as CopilotKit Runtime
participant Backend as LangGraph deployment / FastAPI endpoint
participant Agent as Graph node
Frontend->>CopilotKit: authorization: "user-token"
CopilotKit->>LangGraphAgent: Forward auth token
LangGraphAgent->>Agent: user info via config
Agent->>Agent: Access authenticated user context
Frontend->>Runtime: Authorization: Bearer user-token
Runtime->>Backend: Forward Authorization header
Backend->>Backend: Validate token (401 if invalid)
Backend->>Agent: Verified user via RunnableConfig
Agent->>Agent: Scope tools and data to that user
```
## Frontend Setup
Pass your authentication token via the `properties` prop:
Pass your authentication token via the `headers` prop:
```tsx
<CopilotKit
runtimeUrl="/api/copilotkit"
properties={{
authorization: userToken, // Forwarded as Bearer token
headers={{
Authorization: `Bearer ${userToken}`,
}}
>
<YourApp />
</CopilotKit>
```
**Note**: For LangGraph Platform, the `authorization` property is forwarded as a Bearer token.
The runtime forwards `Authorization` (and any custom `x-*` headers) onto the outgoing agent call. Headers the server explicitly configured on the agent win on collision, so a service-to-service credential can never be overridden from the browser.
**Note**: `properties` is not an auth channel. The runtime delivers `properties` to the agent as AG-UI `forwardedProps` — run payload data — and never converts them into a Bearer header.
## LangGraph Platform Deployment
**For agents deployed to LangGraph Platform**, authentication works out of the box with the `@auth.authenticate` decorator.
**For agents deployed to LangGraph Platform** (or served by `langgraph dev`), authentication works out of the box with the `@auth.authenticate` decorator. The forwarded header arrives as the handler's `authorization` argument.
### Setup Authentication Handler
@@ -78,7 +81,7 @@ from langchain_core.runnables import RunnableConfig
async def my_agent_node(state: AgentState, config: RunnableConfig):
# Access user from LangGraph Platform authentication
user_info = config["configuration"]["langgraph_auth_user"]
user_info = config["configurable"]["langgraph_auth_user"]
user_id = user_info["identity"]
user_role = user_info.get("role")
@@ -90,74 +93,96 @@ For complete implementation details, see the [LangGraph Platform Authentication
## Self-hosted Deployment
**For self-hosted agents**, you need to manually configure authentication context through dynamic agent creation.
**For self-hosted agents** (uvicorn + `ag-ui-langgraph`), you own validation. Do it at the endpoint that serves the AG-UI stream: `add_langgraph_fastapi_endpoint` takes one pre-built agent and offers no per-request hook, so replace it with the equivalent route of your own. A FastAPI dependency verifies the forwarded `Authorization` header, and the resolved user is baked into a per-request agent's `config`.
### Setup Dynamic Agent Configuration
### Serve the AG-UI endpoint yourself
```python
# demo.py - Configure agent with authentication context
from copilotkit import CopilotKitRemoteEndpoint, LangGraphAgent
```python title="main.py"
from typing import Optional
sdk = CopilotKitRemoteEndpoint(
agents=lambda context: [
LangGraphAgent(
name="sample_agent",
description="Agent with authentication support",
graph=graph,
langgraph_config={
"configurable": {
"copilotkit_auth": context["properties"].get("authorization")
}
}
)
],
)
from ag_ui.core.types import RunAgentInput
from ag_ui.encoder import EventEncoder
from copilotkit import LangGraphAGUIAgent
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from src.agent import graph
app = FastAPI()
def current_user(authorization: Optional[str] = Header(default=None)) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
return validate_your_token(authorization.removeprefix("Bearer ").strip()) # your validation
@app.post("/")
async def run_agent(
input_data: RunAgentInput,
request: Request,
user: dict = Depends(current_user),
):
encoder = EventEncoder(accept=request.headers.get("accept"))
# One agent per request: the verified identity rides on this run only, and
# each request gets its own isolated streaming state.
agent = LangGraphAGUIAgent(
name="sample_agent",
description="Agent with authentication support",
graph=graph,
config={"configurable": {"auth_user": user}},
)
async def event_generator():
async for event in agent.run(input_data):
yield encoder.encode(event)
return StreamingResponse(event_generator(), media_type=encoder.get_content_type())
```
If your nodes don't need the identity and you only want unauthenticated traffic rejected, you can keep `add_langgraph_fastapi_endpoint` and hang the dependency off the app instead: `FastAPI(dependencies=[Depends(current_user)])`. The 401 gate applies, but no user context reaches the run config.
### Access User in Agent
```python
from langchain_core.runnables import RunnableConfig
async def my_agent_node(state: AgentState, config: RunnableConfig):
# Handle authentication for self-hosted mode
auth_token = config["configurable"].get("copilotkit_auth")
if auth_token:
user_info = validate_your_token(auth_token)
user_id = user_info["user_id"]
user_role = user_info.get("role")
else:
user_id = "anonymous"
user_role = None
# Already validated by the endpoint — no raw token in the graph
user = config["configurable"]["auth_user"]
user_id = user["user_id"]
user_role = user.get("role")
# Your agent logic with user context
return state
```
<Callout type="warning" title="`CopilotKitRemoteEndpoint` no longer works here">
Guides written for CopilotKit v1 wrapped the graph in `CopilotKitRemoteEndpoint(agents=lambda context: [...])`. That path is retired: `copilotkit` no longer exports `LangGraphAgent` (`ImportError`), and the current `LangGraphAGUIAgent` exposes `run()` rather than the `execute()` that `CopilotKitRemoteEndpoint` calls — giving `AgentExecutionException: 'LangGraphAGUIAgent' object has no attribute 'execute'`. Use the endpoint above instead, and see [Migrate to AG-UI](/langgraph/troubleshooting/migrate-to-agui) for the rest of the move.
</Callout>
## Universal Authentication Pattern
For agents that work in both environments, use this pattern:
For agents that run in both environments, read whichever key the environment populated:
```python
from langchain_core.runnables import RunnableConfig
async def my_agent_node(state: AgentState, config: RunnableConfig):
configurable = config.get("configurable", {})
user_id = "anonymous"
user_role = None
# LangGraph Platform mode
if "configuration" in config and "langgraph_auth_user" in config["configuration"]:
user_info = config["configuration"]["langgraph_auth_user"]
# LangGraph Platform / langgraph dev
if "langgraph_auth_user" in configurable:
user_info = configurable["langgraph_auth_user"]
user_id = user_info["identity"]
user_role = user_info.get("role")
# Self-hosted mode
elif "configurable" in config and "copilotkit_auth" in config["configurable"]:
auth_token = config["configurable"]["copilotkit_auth"]
if auth_token:
user_info = validate_your_token(auth_token)
user_id = user_info["user_id"]
user_role = user_info.get("role")
# Self-hosted (injected by your AG-UI endpoint)
elif "auth_user" in configurable:
user_info = configurable["auth_user"]
user_id = user_info["user_id"]
user_role = user_info.get("role")
# Your agent logic with user context
return state
@@ -173,8 +198,8 @@ async def my_agent_node(state: AgentState, config: RunnableConfig):
### Self-hosted
- **Manual Validation**: You must implement token validation in your agent logic
- **Context Passing**: Authentication context passed through agent configuration
- **Manual Validation**: You must validate the token at your AG-UI endpoint, before the graph runs
- **Context Passing**: Pass the resolved user — not the raw token — through the run config
- **Security Responsibility**: Ensure proper token validation and user scoping
### General Best Practices
@@ -191,18 +216,23 @@ For comprehensive authentication patterns, authorization handlers, and security
**Token not reaching agent**:
- Ensure you're passing `authorization` in the `properties` prop
- For self-hosted: Verify dynamic agent configuration is set up correctly
- Ensure you're passing `Authorization` in the `headers` prop, not in `properties`
- Check that the agent isn't already configured with its own `Authorization` header — server-configured headers win on collision
- If you set a custom `forwardHeaders` policy on the runtime, confirm `authorization` is still allowed by it
**Invalid token format**:
- CopilotKit automatically adds the `Bearer ` prefix for LangGraph Platform
- For self-hosted: Handle token format in your validation logic
- Include the `Bearer ` prefix yourself in the `headers` value; nothing adds it for you
- Strip the prefix before validating (`authorization.removeprefix("Bearer ").strip()`)
**User info not available**:
- **LangGraph Platform**: Verify your `@auth.authenticate` handler is properly configured
- **Self-hosted**: Check that `copilotkit_auth` is properly passed in `langgraph_config`
- **LangGraph Platform**: Verify your `@auth.authenticate` handler is properly configured, and read the user from `config["configurable"]["langgraph_auth_user"]`
- **Self-hosted**: Check that your endpoint builds the agent with `config={"configurable": {...}}` per request — a shared agent built at import time carries no request identity
**`AgentExecutionException: 'LangGraphAGUIAgent' object has no attribute 'execute'`**:
- You're on the retired `CopilotKitRemoteEndpoint` path. Serve the AG-UI endpoint directly, as shown above.
**Authentication works locally but not in production**:
@@ -277,3 +277,17 @@ A custom Button component to use instead of the default.
A custom Header component to use instead of the default.
</PropertyReference>
<PropertyReference name="fullHeightChildren" type="boolean" >
Make the sidebar's content wrapper exactly one viewport tall, so children
can use `height: 100%` (or `flex: 1`) to fill the screen.
Off by default: the wrappers are auto-height, so page content flows
normally and percentage heights on children collapse to content height.
```tsx
<CopilotSidebar fullHeightChildren>
<div style={{ height: "100%" }}>...</div>
</CopilotSidebar>
```
</PropertyReference>