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,54 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
.next/
/out/
# turbo
.turbo
# Build outputs
dist/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# lock files
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lockb
@@ -0,0 +1,135 @@
# langgraph-js-starter
## 0.1.7 — 2026-04-21
### Fixed
- Hardened `emit_unknown_tools_notice` and `intercept_frontend_tools`
against OpenAI tool_call invariant violations and prior-stash
clobbering.
- `useCopilotAction` deps on `setThemeColor` and `getWeather`.
- `next lint` removal in Next 16 — replaced with direct ESLint invocation.
- `route.ts` LANGSMITH warn now gates on NODE_ENV like DEPLOYMENT_URL.
- `route.ts` log prefixes distinguish runtime-construction from dispatch
failures.
- `parseInterruptPayload` single-return-value, caller-owned log.
### Changed
- LICENSE copyright attribution: `2025-2026 CopilotKit` (was: individual).
- README: fixed broken `pnpm --filter` example in troubleshooting;
replaced inline `echo > .env` with `cp .env.example .env` + edit;
corrected project-structure diagram comment to reference
`pnpm-workspace.yaml`.
- `apps/web/tsconfig.json`: dropped dead `.next/dev/types/**` include.
- `apps/web/.env.example`: clarified that LANGSMITH\_\* vars forward to the
agent.
- Root `.gitignore`: ignore `dist/`.
- `turbo.json`: add `start` task.
- `apps/web/project.json`: `cache: false` on `build` target for Nx parity
with package.json.
- `apps/agent/tsconfig.json`: set `noEmit: true` so direct `tsc`
invocation matches script-driven builds.
## 0.1.6 — 2026-04-21
### Changed
- Added `turbo.json` at the starter root so `turbo run dev/build/lint`
works when the starter is extracted standalone (root scripts delegate
to turbo; missing config previously broke extraction).
- Added `pnpm-workspace.yaml` so pnpm v9+ reliably links
`workspace:*` deps on standalone extraction; the `workspaces` array in
the root `package.json` is not honored by pnpm on its own.
- Bumped the agent `tsconfig.json` `target` from `es2016` to `ES2022`
(with explicit `lib: ["ES2022"]`, no DOM) to match the declared
Node 20+ runtime baseline.
- Added `build` and `lint` scripts to `apps/agent/package.json`
(`tsc -p tsconfig.json --noEmit` — identical to what `project.json`
declares) so `turbo run build/lint` and nx targets agree.
- Filled in the empty agent `description` and `author` fields.
- Added `LANGSMITH_TRACING=true` (commented) to `apps/web/.env.example`
to match the agent's `.env.example`.
- Added `"baseUrl": "."` to `apps/web/tsconfig.json` so the `@/*` path
alias resolves reliably across IDEs and tooling.
- Removed Python-specific entries (`venv/`, `__pycache__/`, `*.pyc`)
from `apps/agent/.gitignore` — leftovers from a forked Python
LangGraph starter; this is a TypeScript project.
- Added a copyright year to `LICENSE` (MIT convention requires one).
### Versioning
- Root, `apps/agent`, and `apps/web` all bumped from `0.1.5` to `0.1.6`
in lockstep per the starter's shared-version convention.
## 0.1.5 — 2026-04-17
### Renamed
- Renamed directory from `interrupts-langraph` to `interrupts-langgraph`
(spelling fix). The rename itself is code-neutral; hardening described
below.
### Changed
- Tightened types in the agent graph (no `as any`, structural message
narrowing) and added an explicit `bindTools` capability guard.
- `shouldContinue` now evaluates ALL tool calls on an AIMessage and
routes to `tool_node` whenever ANY call targets a registered backend
tool. Previously a mixed batch (frontend action + backend tool) could
silently drop the backend call. Each unknown tool-call name emits its
own `console.warn`; routing then keeps the batch on `tool_node` when
any known backend tool is present (ToolNode will emit an error
ToolMessage for unknown tool names; the graph then loops back to
`chat_node` with that error in context) and falls through to `END`
otherwise.
- Validated the interrupt payload on the web side via
`parseInterruptPayload`; malformed payloads now render a cancellation
fallback instead of crashing the renderer. Arrays are explicitly
rejected (previously passed the `typeof === "object"` check and were
coerced to the `Record` lookup path).
- Validated the resumed interrupt value on the agent side via a zod
schema (`ApprovalResumeSchema`); an out-of-band Client resuming with
the wrong shape now fails loudly at the tool boundary instead of
silently branching to "cancelled".
- `deleteProverb` on the web side now uses a functional `setState`
updater so concurrent state writes don't race.
- React key for the proverb list is `${index}-${proverb}` (index + content
composite). Chosen because plain `proverb` collides on duplicates and
plain `index` destabilizes rows during agent-driven inserts. Migrating
the underlying state to `{id, text}` objects is the proper long-term
fix and is deferred.
- Removed the unused `starterAgent` alias; extracted the model name
to a `MODEL` constant for single-point swaps.
- Wrapped `handleRequest` with a structured error response in the web
runtime route (`apps/web/src/app/api/copilotkit/route.ts`), so
unhandled exceptions surface as a structured 500 JSON response rather
than the raw Next.js error page.
- Corrected port references in the README (8125 everywhere).
- Replaced the placeholder Next metadata with a real title/description.
### Dependencies
- `@types/node` ^20 → ^22.19.11
- `typescript` ^5 → ^5.9.3
- `zod` ^3.24.4 → ^3.25.76
- Agent `@langchain/langgraph``1.1.5` (previously pinned via a root
`overrides` entry at `1.0.2`; the override has been removed and the
version is now declared directly on `apps/agent/package.json`).
- Agent `@langchain/core``^1.1.26`.
- Dropped the dead `@langchain/core` override from the starter root; it
had no effect inside the monorepo pnpm workspace and would cap the
agent's `^1.1.26` requirement if the starter were extracted.
- Removed root `overrides` entry for `@langchain/langgraph` (was `1.0.2`);
the agent owns its own version now.
### Versioning
- Sub-app versions synced to the root: `apps/agent` `0.0.1``0.1.5` and
`apps/web` `0.1.0``0.1.5`. Convention: sub-app versions track the
root starter version so changelog entries and `package.json` reads
stay consistent across the workspace.
## 0.1.1 0.1.4
Internal only (dependency sync / tooling bumps).
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2025-2026 CopilotKit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+107
View File
@@ -0,0 +1,107 @@
# CopilotKit <> LangGraph Starter
This is a starter template for building AI agents using [LangGraph](https://www.langchain.com/langgraph) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated LangGraph agent to be built on top of.
This project is organized as a monorepo using [pnpm workspaces](https://pnpm.io/workspaces).
## Project Structure
```
.
├── apps/
│ ├── web/ # Next.js frontend application
│ └── agent/ # LangGraph agent
└── package.json # pnpm workspaces via pnpm-workspace.yaml
```
## Prerequisites
- Node.js 20+
- [pnpm](https://pnpm.io/installation) 9.15.0 or later
- OpenAI API Key (for the LangGraph agent)
## Getting Started
1. Install all dependencies (this installs everything for both apps):
```bash
pnpm install
```
2. Set up your OpenAI API key by copying the example env file and editing it:
```bash
cp apps/agent/.env.example apps/agent/.env
```
Then open `apps/agent/.env` in your editor and fill in `OPENAI_API_KEY` with your key.
For production, also copy `apps/web/.env.example` to `apps/web/.env` and set `LANGGRAPH_DEPLOYMENT_URL` to your deployed agent URL.
3. Start the development servers:
```bash
pnpm dev
```
This will start both the Next.js app (on port 3000) and the LangGraph agent (on port 8125) via Turbo (installed as a dev dependency).
## Available Scripts
All scripts use Turbo to run tasks across the workspace:
- `pnpm dev` - Starts both the web app and agent servers in development mode
- `pnpm build` - Builds all apps for production
- `pnpm lint` - Runs linting across all apps
### Running Scripts for Individual Apps
You can also run scripts for individual apps using pnpm's filter flag:
```bash
# Run dev for just the web app
pnpm --filter web-langgraph-interrupt dev
# Run dev for just the agent
pnpm --filter agent-langgraph-interrupt dev
# Or navigate to the app directory
cd apps/web
pnpm dev
```
## Customization
The main UI component is in `apps/web/src/app/page.tsx`. You can:
- Modify the theme colors and styling
- Add new frontend actions
- Utilize shared-state
- Customize your user-interface for interacting with LangGraph
The LangGraph agent code is in `apps/agent/src/`.
## 📚 Documentation
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Learn more about LangGraph and its features
- [Next.js Documentation](https://nextjs.org/docs) - Learn about Next.js features and API
## Contributing
Feel free to submit issues and enhancement requests! This starter is designed to be easily extensible.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Troubleshooting
### Agent Connection Issues
**If the chat returns an "Internal error while dispatching CopilotKit request" 500:**
Check the Next.js server logs for `[copilotkit/route] runtime construction failed:` or `[copilotkit/route] handleRequest dispatch failed:`. Common causes:
- `LANGGRAPH_DEPLOYMENT_URL` unset in production (required — check `apps/web/.env`)
- LangGraph server not running at the configured URL (check `pnpm --filter agent-langgraph-interrupt dev` started cleanly)
- `OPENAI_API_KEY` missing from `apps/agent/.env`
@@ -0,0 +1,7 @@
# OpenAI API key (required)
OPENAI_API_KEY=
# LangSmith tracing (optional)
# LANGSMITH_API_KEY=
# LANGSMITH_TRACING=true
# LANGSMITH_PROJECT=interrupts-langgraph
@@ -0,0 +1,6 @@
.env
.vercel
# LangGraph API
.langgraph_api
node_modules/
@@ -0,0 +1,9 @@
{
"node_version": "20",
"dockerfile_lines": [],
"dependencies": ["."],
"graphs": {
"default": "./src/agent.ts:graph"
},
"env": ".env"
}
@@ -0,0 +1,28 @@
{
"name": "agent-langgraph-interrupt",
"version": "0.1.7",
"private": true,
"description": "LangGraph agent for the CopilotKit interrupts starter",
"keywords": [],
"license": "MIT",
"author": "CopilotKit",
"scripts": {
"dev": "npx @langchain/langgraph-cli dev --port 8125 --no-browser",
"build": "tsc -p tsconfig.json --noEmit",
"lint": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@copilotkit/sdk-js": "workspace:*",
"@langchain/core": "^1.1.26",
"@langchain/langgraph": "1.1.5",
"@langchain/openai": "^1.2.8",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^22.19.11",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,29 @@
{
"name": "agent-langgraph-interrupt",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/agent/src",
"projectType": "application",
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter agent-langgraph-interrupt dev"
}
},
"build": {
"executor": "nx:run-commands",
"options": {
"command": "tsc -p tsconfig.json --noEmit",
"cwd": "examples/v2/interrupts-langgraph/apps/agent"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "tsc -p tsconfig.json --noEmit",
"cwd": "examples/v2/interrupts-langgraph/apps/agent"
}
}
},
"tags": []
}
@@ -0,0 +1,924 @@
/**
* This is the main entry point for the agent.
* It defines the workflow graph, state, tools, nodes and edges.
*/
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { RunnableConfig } from "@langchain/core/runnables";
import { tool } from "@langchain/core/tools";
import type { ToolRunnableConfig } from "@langchain/core/tools";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import type { BaseMessage, ToolCall } from "@langchain/core/messages";
import {
AIMessage,
isAIMessage,
SystemMessage,
ToolMessage,
} from "@langchain/core/messages";
import {
Annotation,
Command,
END,
getCurrentTaskInput,
interrupt,
MemorySaver,
START,
StateGraph,
} from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import {
convertActionsToDynamicStructuredTools,
CopilotKitStateAnnotation,
} from "@copilotkit/sdk-js/langgraph";
// Include CopilotKitStateAnnotation so the frontend can attach actions and
// so messages flow through the same channel the SDK expects.
//
// `interceptedToolCalls` + `originalAIMessageId` mirror
// `@copilotkit/sdk-js/langgraph`'s `copilotkitMiddleware.afterModel`
// intercept pattern (see node_modules/@copilotkit/sdk-js/src/langgraph/middleware.ts):
// on a mixed batch (backend tool call + frontend-action call in the same
// AIMessage), we strip the frontend calls out of the AIMessage before
// ToolNode runs (otherwise ToolNode errors on "Tool not found" for the
// frontend action names), stash them here, then restore them onto the
// original AIMessage before the graph ends so the frontend runtime still
// dispatches them. Raw-StateGraph starters like this one don't use
// createAgent+middleware, so we reproduce the pattern inline.
const AgentStateAnnotation = Annotation.Root({
...CopilotKitStateAnnotation.spec,
proverbs: Annotation<string[]>,
interceptedToolCalls: Annotation<ToolCall[] | undefined>,
originalAIMessageId: Annotation<string | undefined>,
});
export type AgentState = typeof AgentStateAnnotation.State;
// The renderer in apps/web/src/app/page.tsx validates the *emitted* interrupt
// payload against its own `parseInterruptPayload` shape. We validate the
// *resumed* payload here so an out-of-band Client that resumes with the wrong
// shape fails loudly at the tool boundary instead of silently branching to
// "cancelled".
const ApprovalResumeSchema = z.object({
approved: z.boolean(),
});
const getWeather = tool(
(args) => {
return `The weather for ${args.location} is 70 degrees, clear skies, 45% humidity, 5 mph wind, and feels like 72 degrees.`;
},
{
name: "getWeather",
description: "Get the weather for a given location.",
schema: z.object({
location: z.string().describe("The location to get weather for"),
}),
},
);
// HITL tool: triggers an interrupt that the frontend resolves with
// `{ approved: boolean }`. Validated with zod so a malformed resume value
// surfaces as a deterministic tool message rather than throwing through
// ToolNode.
//
// On approval, returns a `Command` that BOTH emits a ToolMessage (so the
// model sees the tool result) AND applies a state update that removes the
// matching proverb from `state.proverbs`. Without the state update, the
// UI (which reads `state.proverbs` via CopilotKit) would still show the
// "deleted" proverb, making the HITL demo a sham.
const deleteProverb = tool(
async (args, config: ToolRunnableConfig) => {
// `config.toolCall.id` is the canonical id accessor when a tool is
// invoked by ToolNode. ToolNode calls `tool.invoke({...call, type:
// "tool_call"}, config)` (see
// node_modules/@langchain/langgraph/dist/prebuilt/tool_node.js runTool),
// and @langchain/core's StructuredTool.invoke then copies the call
// onto `enrichedConfig.toolCall` (see
// node_modules/@langchain/core/dist/tools/index.js lines 84-91) before
// forwarding to the tool function. This is typed on `ToolRunnableConfig`
// — typing `config` explicitly above is what gives us the safe accessor.
//
// We need the id here because returning a `Command` bypasses
// `_formatToolOutput`'s automatic tool_call_id wiring. If the id is
// somehow missing we throw loudly rather than silently emitting
// `tool_call_id: ""`, which OpenAI rejects on the next turn with
// "tool_call_id does not match any preceding tool_calls".
const toolCallId = config.toolCall?.id;
if (typeof toolCallId !== "string" || toolCallId.length === 0) {
throw new Error(
"deleteProverb: missing tool_call_id on ToolRunnableConfig.toolCall — " +
"tool was invoked outside a ToolNode context. Refusing to emit a " +
"ToolMessage with an empty tool_call_id (OpenAI rejects those).",
);
}
const rawApproval = interrupt({
action: "delete_proverb",
proverb: args.proverb,
message: `Are you sure you want to delete the proverb: "${args.proverb}"?`,
});
let approval: z.infer<typeof ApprovalResumeSchema>;
try {
approval = ApprovalResumeSchema.parse(rawApproval);
} catch (err) {
// Only swallow ZodError — any other throw (programming errors, runtime
// failures, etc.) must propagate so we don't mask real bugs behind a
// generic tool message.
if (!(err instanceof z.ZodError)) {
throw err;
}
// eslint-disable-next-line no-console
console.error("[deleteProverb] resume payload rejected:", err.issues);
// Don't let ZodError propagate through ToolNode. Return a
// deterministic tool message so the graph can loop back to chat_node
// with a readable result in context.
return new ToolMessage({
status: "error",
name: "deleteProverb",
tool_call_id: toolCallId,
content:
"Confirmation failed due to an unexpected resume payload shape; deletion was NOT performed.",
});
}
if (approval.approved) {
// Read the current graph state via LangGraph's task-local accessor so
// we can filter `proverbs` deterministically. We match by content
// (the schema accepts the proverb text). If multiple proverbs tie
// exactly, only the first matching entry is removed — consistent
// with "delete the proverb the user named".
//
// AgentStateAnnotation's `proverbs` channel has no reducer, so
// Annotation<string[]> defaults to last-write-wins: emitting a
// filtered array replaces the channel wholesale (which is exactly
// what chat_node reads on the next turn for the system prompt).
const currentState = getCurrentTaskInput<AgentState>();
const current = Array.isArray(currentState?.proverbs)
? currentState.proverbs
: [];
const idx = current.indexOf(args.proverb);
// Approved-but-not-present: do not lie to the model. Return an
// error ToolMessage so the model sees "nothing matched" and can
// respond truthfully instead of confirming a deletion that never
// happened.
if (idx === -1) {
return new Command({
update: {
messages: [
new ToolMessage({
status: "error",
name: "deleteProverb",
tool_call_id: toolCallId,
content: `No proverb matching "${args.proverb}" was found; nothing was deleted.`,
}),
],
},
});
}
const filtered = [...current.slice(0, idx), ...current.slice(idx + 1)];
return new Command({
update: {
messages: [
new ToolMessage({
status: "success",
name: "deleteProverb",
tool_call_id: toolCallId,
content: `Proverb "${args.proverb}" has been deleted.`,
}),
],
proverbs: filtered,
},
});
}
// Mirror the approved branch: return a Command wrapping a ToolMessage
// so the model sees a well-formed tool result with the correct
// tool_call_id (OpenAI rejects tool messages with mismatched ids).
// Cancellation is not success — the tool did not complete its stated
// intent — so the ToolMessage status is "error". The content string is
// truthful as a user-cancelled message.
return new Command({
update: {
messages: [
new ToolMessage({
status: "error",
name: "deleteProverb",
tool_call_id: toolCallId,
content: `Deletion of proverb "${args.proverb}" was cancelled by the user.`,
}),
],
},
});
},
{
name: "deleteProverb",
description:
"Delete a proverb from the list. This will ask the user for confirmation before deleting.",
schema: z.object({
proverb: z.string().describe("The proverb to delete"),
}),
},
);
const tools = [getWeather, deleteProverb];
// gpt-4o-mini: reliable tool-calling, low cost. Swap model here if you
// need different tradeoffs.
const MODEL = "gpt-4o-mini";
async function chat_node(state: AgentState, config: RunnableConfig) {
const model = new ChatOpenAI({ model: MODEL });
// Bind tools to the model, including CopilotKit frontend actions.
//
// bindTools is optional on BaseChatModel's type; guard explicitly instead
// of using the non-null `!` escape hatch so a model instance that doesn't
// support tool binding fails loudly.
if (typeof model.bindTools !== "function") {
throw new Error(
`ChatOpenAI instance for model "${MODEL}" does not expose bindTools; cannot bind tools for this model.`,
);
}
const modelWithTools = model.bindTools([
...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []),
...tools,
]);
const systemMessage = new SystemMessage({
content: `You are a helpful assistant. The current proverbs are ${JSON.stringify(state.proverbs ?? [])}. If a user asks to delete a proverb, call deleteProverb to trigger a human-in-the-loop interrupt for confirmation.`,
});
const response = await modelWithTools.invoke(
[systemMessage, ...(state.messages ?? [])],
config,
);
return {
messages: [response],
};
}
// intercept_frontend_tools: strips frontend-action tool_calls out of the
// last AIMessage before ToolNode runs and stashes them in state.
//
// ToolNode only knows about backend `tools` (getWeather, deleteProverb); it
// looks up each `tool_call.name` in its own registry and throws
// "Tool not found" for any frontend-action name (see
// node_modules/@langchain/langgraph/dist/prebuilt/tool_node.js, runTool).
// On a mixed batch that would leave backend results AND a model-visible
// error ToolMessage for the frontend action, and the frontend would never
// see the frontend-action call at all.
//
// The intercept+restore pattern mirrors CopilotKit's own
// `copilotkitMiddleware.afterModel` + `afterAgent` (see
// node_modules/@copilotkit/sdk-js/src/langgraph/middleware.ts). The
// `restore_frontend_tools` node below reattaches the stashed calls to the
// original AIMessage (matched by id) before the graph ends so the
// CopilotKit runtime still dispatches them to the frontend.
//
// Pure-frontend-only batches skip this node entirely — shouldContinue
// routes them straight to END, and the AIMessage with their tool_calls
// reaches the frontend as-is.
// Rebuild an AIMessage with a different tool_calls set while preserving
// every other field (additional_kwargs, response_metadata, usage_metadata,
// name, invalid_tool_calls, id, content). Required for LangSmith tracing
// + token accounting — naively constructing `new AIMessage({ content,
// tool_calls, id })` drops everything else.
//
// Note: `tool_call_chunks` only exists on AIMessageChunk, and the AIMessage
// constructor does not accept it — preserving it here was a no-op. Omitted.
function rebuildAIMessageWithToolCalls(
source: AIMessage,
toolCalls: ToolCall[],
): AIMessage {
return new AIMessage({
content: source.content,
id: source.id,
name: source.name,
additional_kwargs: source.additional_kwargs,
response_metadata: source.response_metadata,
usage_metadata: source.usage_metadata,
invalid_tool_calls: source.invalid_tool_calls,
tool_calls: toolCalls,
});
}
function intercept_frontend_tools(state: AgentState) {
const frontendActionNames = new Set(
(state.copilotkit?.actions ?? []).map((a: { name: string }) => a.name),
);
if (frontendActionNames.size === 0) {
return {};
}
// Widen to our directly-imported `BaseMessage` type so `isAIMessage`
// (1.1.27) can narrow a `state.messages[i]` (structurally identical
// 1.1.40) without the pnpm nominal-mismatch error. Runtime values are
// unaffected — see the matching annotation on `lastMessage` in
// `shouldContinue`.
let messages = (state.messages ?? []) as unknown as BaseMessage[];
// The per-turn intercept slot is a single pair (interceptedToolCalls +
// originalAIMessageId) with no reducer on the annotation — it cannot
// queue. If the graph re-enters this node for a second mixed batch in
// the same thread before `restore_frontend_tools` has flushed the prior
// stash, we must flush the previous stash onto its matching AIMessage
// inline here. Otherwise last-write-wins would silently drop the
// earlier frontend-action calls and the frontend would never see them.
//
// Flush strategy on re-entry:
// (a) First pass: walk `messages` and reattach the prior stash onto
// the AIMessage whose id matches `priorOriginalId` (pre-strip).
// (b) If (a) finds no match AND this pass would otherwise overwrite
// the slot with a new stash (the mixed-batch return below), we
// make a second flush attempt against the newly-rewritten
// `messages` array (post-strip) before committing the new stash.
// (c) If both attempts fail, we emit a loud warn naming the lost
// AIMessage id + tool-call ids and STILL write the new stash —
// merging two different AIMessage ids into one slot would corrupt
// `originalAIMessageId`. The warn is the escape valve.
//
// The `frontendToolCalls.length === 0` return branch applies a
// flush-or-clear rule: if the pre-strip flush matched (prior_flushed),
// the updated messages are emitted and the slot is cleared; if it
// didn't match but a prior stash was present, the slot is cleared
// with a warn (retaining it risks double-flushing onto the original
// AIMessage on a subsequent non-stripping turn since the AIMessage
// may still be in history).
const priorIntercepted = state.interceptedToolCalls;
const priorOriginalId = state.originalAIMessageId;
const priorSlotPresent =
!!priorIntercepted &&
priorIntercepted.length > 0 &&
typeof priorOriginalId === "string" &&
priorOriginalId.length > 0;
let prior_flushed = false;
if (priorSlotPresent) {
messages = messages.map((msg) => {
if (isAIMessage(msg) && msg.id === priorOriginalId) {
prior_flushed = true;
const existing = msg.tool_calls ?? [];
return rebuildAIMessageWithToolCalls(msg, [
...existing,
...priorIntercepted!,
]);
}
return msg;
});
if (!prior_flushed) {
// eslint-disable-next-line no-console
console.warn(
`[intercept_frontend_tools] prior intercept slot held id=${priorOriginalId} but no matching AIMessage was found to flush onto (pre-strip); downstream branches will flush-or-clear.`,
);
}
}
const lastMessage: BaseMessage | undefined = messages[messages.length - 1];
if (lastMessage === undefined || !isAIMessage(lastMessage)) {
return {};
}
const toolCalls = lastMessage.tool_calls ?? [];
const backendToolCalls: ToolCall[] = [];
const frontendToolCalls: ToolCall[] = [];
for (const call of toolCalls) {
if (frontendActionNames.has(call.name)) {
frontendToolCalls.push(call);
} else {
backendToolCalls.push(call);
}
}
// `AIMessage.id` is typed `string | undefined` in @langchain/core. If the
// upstream provider (or a test fixture) produced an AIMessage without an
// id AND this batch needs stripping, `restore_frontend_tools` would never
// find a matching id on the later pass — frontend-action calls would be
// silently dropped and the user would see nothing. Synthesize a stable
// id in place so the strip/stash/restore chain can match. LangChain
// AIMessage is mutable; the synthesized id survives `rebuildAIMessageWithToolCalls`
// (which copies `id` from `source.id`) and lives on the same object
// reference in `messages`.
if (
frontendToolCalls.length > 0 &&
(typeof lastMessage.id !== "string" || lastMessage.id.length === 0)
) {
const synthesizedId = `synthesized-${randomUUID()}`;
// eslint-disable-next-line no-console
console.warn(
`[intercept_frontend_tools] lastMessage.id is missing on an AIMessage with ${frontendToolCalls.length} frontend-action call(s); synthesizing id=${synthesizedId} so restore_frontend_tools can match. Upstream provider should supply stable AIMessage ids.`,
);
(lastMessage as AIMessage).id = synthesizedId;
}
if (frontendToolCalls.length === 0) {
// No frontend calls in the batch — nothing to strip.
//
// Three prior-slot cases to distinguish:
// (a) prior_flushed === true: we reattached the stashed calls onto
// a matching AIMessage above; emit the updated messages and
// clear the slot.
// (b) priorSlotPresent && !prior_flushed: no matching AIMessage was
// found THIS pass. Over a sequence like
// [mixed-stash] → [backend-only+unknown] → [pure-backend],
// retaining the stash across multiple non-stripping turns would
// eventually let `restore_frontend_tools` re-apply it to an
// unrelated AIMessage (or let a future intercept pass
// double-append onto the original AIMessage still in history).
// Flush-or-clear discipline: if we had a stash and this path
// isn't stripping, clear it. Emit a warn so operators can
// debug the dropped frontend-action dispatch.
// (c) No prior slot: no-op.
if (prior_flushed) {
return {
messages,
interceptedToolCalls: undefined,
originalAIMessageId: undefined,
} as unknown as Partial<AgentState>;
}
if (priorSlotPresent) {
const lostIds = priorIntercepted!
.map((c) => c.id ?? "<no-id>")
.join(", ");
// eslint-disable-next-line no-console
console.warn(
`[intercept_frontend_tools] prior intercept slot held id=${priorOriginalId} but no matching AIMessage was found and this path isn't stripping; clearing stash to prevent later re-application onto an unrelated AIMessage. Lost tool-call ids: [${lostIds}]`,
);
return {
interceptedToolCalls: undefined,
originalAIMessageId: undefined,
} as unknown as Partial<AgentState>;
}
return {};
}
// Rebuild the AIMessage preserving id (so restore_frontend_tools can
// find it later) AND all other metadata (additional_kwargs,
// response_metadata, usage_metadata, etc.) with only the backend calls.
const strippedAIMessage = rebuildAIMessageWithToolCalls(
lastMessage,
backendToolCalls,
);
// Compose the outgoing message list with the strip applied so any
// post-strip flush attempt sees the final shape.
let outgoingMessages: BaseMessage[] = [
...messages.slice(0, -1),
strippedAIMessage,
];
// Mixed-batch overwrite guard: if a prior stash is still present and
// was NOT flushed in the pre-strip pass above, we are about to
// overwrite the slot. Try one more flush against the post-strip
// `outgoingMessages` before giving up. If still unmatched, warn
// loudly — merging different AIMessage ids into one slot would
// corrupt `originalAIMessageId`, so we accept losing the prior stash
// in exchange for a coherent new one. The warn mirrors the
// "no matching AIMessage" style used by `restore_frontend_tools`.
if (priorSlotPresent && !prior_flushed) {
let lateFlushed = false;
outgoingMessages = outgoingMessages.map((msg) => {
if (isAIMessage(msg) && msg.id === priorOriginalId) {
lateFlushed = true;
const existing = msg.tool_calls ?? [];
return rebuildAIMessageWithToolCalls(msg, [
...existing,
...priorIntercepted!,
]);
}
return msg;
});
if (!lateFlushed) {
const lostIds = priorIntercepted!
.map((c) => c.id ?? "<no-id>")
.join(", ");
// eslint-disable-next-line no-console
console.warn(
`[intercept_frontend_tools] prior intercept slot held id=${priorOriginalId} but no matching AIMessage was found to flush onto (pre- or post-strip); overwriting stash with current mixed-batch intercept. Lost tool-call ids: [${lostIds}]`,
);
}
}
// The outer cast passes the return past a pre-existing pnpm monorepo
// resolution quirk: `@langchain/langgraph@1.1.5` pins `@langchain/core`
// at a different patch level than this agent's direct dep, so our
// imported `AIMessage/BaseMessage` and the graph-state's internal
// version are nominally distinct types though structurally identical
// at runtime. chat_node's `return { messages: [response] }` hits the
// same mismatch implicitly; cf. the baseline tsc errors on that line.
return {
messages: outgoingMessages,
interceptedToolCalls: frontendToolCalls,
originalAIMessageId: lastMessage.id,
} as unknown as Partial<AgentState>;
}
// restore_frontend_tools: reattaches the stashed frontend-action tool_calls
// to the original AIMessage (matched by id) so the CopilotKit runtime can
// dispatch them to the frontend. Mirrors `copilotkitMiddleware.afterAgent`.
function restore_frontend_tools(state: AgentState) {
const interceptedToolCalls = state.interceptedToolCalls;
const originalMessageId = state.originalAIMessageId;
if (
!interceptedToolCalls ||
interceptedToolCalls.length === 0 ||
!originalMessageId
) {
return {};
}
// Widen to our directly-imported `BaseMessage` for `isAIMessage` —
// see the matching cast in `intercept_frontend_tools` above.
const messages = (state.messages ?? []) as unknown as BaseMessage[];
let messageFound = false;
const updatedMessages: BaseMessage[] = messages.map((msg) => {
if (isAIMessage(msg) && msg.id === originalMessageId) {
messageFound = true;
const existing = msg.tool_calls ?? [];
// Preserve all AIMessage metadata (additional_kwargs,
// response_metadata, usage_metadata, name, invalid_tool_calls)
// — a naive rebuild drops them and breaks LangSmith tracing +
// token accounting.
return rebuildAIMessageWithToolCalls(msg, [
...existing,
...interceptedToolCalls,
]);
}
return msg;
});
if (!messageFound) {
// This node is terminal (edge goes to END). Clear both slots so a
// stale stash can't be flushed onto an unrelated AIMessage on a
// later intercept pass. The warn is the diagnostic signal —
// persisting the slot would corrupt future turns rather than help
// diagnose this one.
// eslint-disable-next-line no-console
console.warn(
`[restore_frontend_tools] original AIMessage id=${originalMessageId} not found in messages; clearing stash to avoid cross-turn corruption`,
);
return {
interceptedToolCalls: undefined,
originalAIMessageId: undefined,
} as unknown as Partial<AgentState>;
}
// See note on the matching return in `intercept_frontend_tools`.
return {
messages: updatedMessages,
interceptedToolCalls: undefined,
originalAIMessageId: undefined,
} as unknown as Partial<AgentState>;
}
// The return type is the union of node names plus END, matching the
// shape addConditionalEdges expects from its callback.
function shouldContinue({
messages,
copilotkit,
}: AgentState):
| "intercept_frontend_tools"
| "tool_node"
| "restore_frontend_tools"
| "emit_unknown_tools_notice"
| typeof END {
// Guard the tool-call-carrying variant structurally instead of casting
// BaseMessage to AIMessage.
const lastMessage: BaseMessage | undefined = messages[messages.length - 1];
if (lastMessage === undefined) {
return END;
}
// AIMessage is the only message variant that carries tool_calls. Use
// the `isAIMessage` type predicate from @langchain/core/messages so the
// narrowing is checked rather than cast.
if (!isAIMessage(lastMessage)) {
const kind = lastMessage._getType();
// Log and fall through to END in both dev and prod so a graph-shape
// bug doesn't crash the process. Tradeoff: the user sees the turn
// end silently (no synthetic error message surfaced to the chat).
// Follow-up: emit a synthetic AIMessage from chat_node (not this
// routing function) the next time we observe unexpected internal
// state, so the user sees "I hit an unexpected internal state —
// please try rephrasing."
// eslint-disable-next-line no-console
console.warn("[shouldContinue] unexpected last message type:", kind);
return END;
}
// Evaluate ALL tool calls. If ANY tool call targets a backend tool (i.e.
// not a CopilotKit frontend action), we must route to `tool_node` so the
// backend tool runs — returning END on mixed batches would silently
// drop the backend call.
const toolCalls = lastMessage.tool_calls ?? [];
if (toolCalls.length > 0) {
const actionNames = new Set((copilotkit?.actions ?? []).map((a) => a.name));
// Widen to `Set<string>` because TypeScript's `Set<T>.has` parameter
// is invariant on T — a `Set<"getWeather" | "deleteProverb">` would
// reject a caller-supplied plain `string` at compile time even though
// the runtime answer (`false` for unknown names) is exactly what we
// want.
const backendToolNames = new Set<string>(tools.map((t) => t.name));
let hasBackendTool = false;
let hasFrontendAction = false;
let hasUnknown = false;
for (const toolCall of toolCalls) {
const name = toolCall.name;
if (actionNames.has(name)) {
hasFrontendAction = true;
// Frontend action — handled client-side.
continue;
}
if (backendToolNames.has(name)) {
hasBackendTool = true;
continue;
}
// Unknown name: neither a frontend action nor a registered backend
// tool. Track it so we can route unknown-bearing batches through
// emit_unknown_tools_notice (below), which synthesizes error
// ToolMessages for each unknown call and strips them off the
// AIMessage so the frontend runtime never sees them.
hasUnknown = true;
// eslint-disable-next-line no-console
console.warn(
`[shouldContinue] unknown tool call name '${name}' — will route through emit_unknown_tools_notice unless a known backend tool is also present in this batch`,
);
}
// Mixed batch (backend + frontend action): route through the intercept
// node first so ToolNode doesn't choke on the frontend-action call,
// then tool_node executes backend calls, then chat_node (looped) will
// reach END via the restore node.
//
// Note: if the batch also contains unknown calls, we still prefer
// "tool_node" over the unknown-notice path when a backend call is
// present — ToolNode itself emits an error ToolMessage for unknown
// names and the graph loops back to chat_node with that context.
// The unknown-notice path is reserved for batches that would otherwise
// end the turn without running tool_node.
if (hasBackendTool && hasFrontendAction) {
return "intercept_frontend_tools";
}
if (hasBackendTool) {
return "tool_node";
}
// No backend tool. If the batch carries ANY unknown calls (with or
// without frontend actions), route through emit_unknown_tools_notice
// so:
// (a) error ToolMessages are synthesized for each unknown call,
// keeping the AIMessage+ToolMessage sequence well-formed for
// OpenAI on the next turn (no dangling tool_calls);
// (b) the AIMessage retains its unknown tool_calls alongside the
// knownCalls so every errorToolMessage has a matching
// preceding tool_call.id. The frontend runtime does not
// re-dispatch calls that carry a matching ToolMessage result.
// (c) in the frontend-action + unknown mixed case, the surviving
// frontend-action calls still reach the frontend via the
// terminal restore path (emit_unknown_tools_notice goes to END
// and the rebuilt AIMessage retains both the known frontend
// calls and the unknown calls, the latter pre-resolved by
// their error ToolMessages).
if (hasUnknown) {
return "emit_unknown_tools_notice";
}
}
// All paths that reach here are assistant replies with no pending backend tool_calls.
// Route through restore_frontend_tools; it is a no-op when nothing was intercepted.
return "restore_frontend_tools";
}
// emit_unknown_tools_notice: when the model emits a tool_calls batch that
// includes names the agent cannot dispatch (neither a registered backend
// tool nor a frontend action), chat_node's conditional edge routes here.
// Responsibilities:
//
// 1. Synthesize an error ToolMessage for each unknown tool_call that
// carries a non-empty `call.id`. Without this, the AIMessage's
// unresolved tool_calls leave a dangling tool-use turn — OpenAI
// rejects any AIMessage with tool_calls not followed by matching
// ToolMessages on the NEXT user turn, poisoning the conversation.
// Unknown calls with a missing/empty id are DROPPED from both the
// ToolMessage list AND the AIMessage's tool_calls (emitting
// `tool_call_id: ""` would itself be rejected; keeping the call on
// the AIMessage without a matching ToolMessage re-introduces the
// dangling-reference bug).
// 2. Rebuild the prior AIMessage with rebuildAIMessageWithToolCalls,
// retaining BOTH the knownCalls AND every unknown call whose
// tool_call_id was retained in (1). The retained unknowns are
// what makes the transcript well-formed: each errorToolMessage
// emitted in (1) has a matching `tool_call.id` on the immediately
// preceding AIMessage, which is what OpenAI's chat-completions
// API validates on the next user turn. Stripping the unknowns
// here (as an earlier revision did) while still appending their
// error ToolMessages produced orphaned `tool_call_id`s and
// "tool_call_id does not match any preceding tool_calls" errors
// on the next turn.
// Dropped-id unknowns — those with no usable tool_call_id — are
// still omitted from tool_calls since they have no matching
// ToolMessage result; keeping them would reintroduce the dangling
// reference on the next turn.
// Safety for the frontend: tool_calls on the AIMessage are
// dispatched by the CopilotKit frontend runtime only when the
// model streams TOOL_CALL_START / TOOL_CALL_END events in the
// current turn. This node does not emit those events — it only
// writes to state.messages. On the next turn the frontend sees
// each retained unknown paired with its error ToolMessage in the
// snapshot; pairs that already carry a result are not
// re-dispatched.
// 3. In the PURE-unknown batch (`knownCalls.length === 0`), append a
// user-visible AIMessage notice so the turn doesn't end silently,
// and clear any stale intercept slot from a prior turn.
// In the MIXED frontend-action + unknown batch, do NOT append the
// notice — the surviving frontend-action tool_calls on
// strippedAIMessage need matching ToolMessages on the next turn,
// and a trailing AIMessage(notice) produces an ill-formed OpenAI
// transcript. The surviving calls reach the frontend via the
// outgoing `restore_frontend_tools` → END path.
//
// Routing: outgoing edge goes to `restore_frontend_tools` (not END).
// That node no-ops when the slot is empty, so the pure-unknown case
// still terminates cleanly, while the mixed case gets canonical
// restore-then-END handling and any prior unflushed stash is cleared.
//
// Conditional edges are pure routing functions — they cannot mutate
// state — so the state rewrite lives here.
function emit_unknown_tools_notice(state: AgentState) {
const messages = (state.messages ?? []) as unknown as BaseMessage[];
const lastMessage: BaseMessage | undefined = messages[messages.length - 1];
if (lastMessage === undefined || !isAIMessage(lastMessage)) {
return {};
}
// Mirror shouldContinue's partition logic so the same known-set defines
// what counts as unknown. `tools` is the backend registry; the frontend
// action set comes from state.copilotkit.actions.
const frontendActionNames = new Set(
(state.copilotkit?.actions ?? []).map((a: { name: string }) => a.name),
);
const backendToolNames = new Set<string>(tools.map((t) => t.name));
const allCalls = lastMessage.tool_calls ?? [];
const knownCalls: ToolCall[] = [];
const unknownCalls: ToolCall[] = [];
for (const call of allCalls) {
if (frontendActionNames.has(call.name) || backendToolNames.has(call.name)) {
knownCalls.push(call);
} else {
unknownCalls.push(call);
}
}
if (unknownCalls.length === 0) {
// Nothing unknown to notify about — shouldContinue shouldn't have
// routed here, but be defensive.
return {};
}
// Partition unknowns by whether they carry a usable tool_call_id.
// OpenAI rejects ToolMessages whose `tool_call_id` doesn't match a
// preceding AIMessage tool_call id — including empty strings. The
// only safe handling for an unknown tool_call with a missing/empty
// id is to DROP IT from both the error ToolMessage list AND the
// AIMessage's tool_calls (so no dangling reference remains). This
// mirrors `deleteProverb`'s refusal to emit `tool_call_id: ""`.
const unknownWithId: ToolCall[] = [];
for (const call of unknownCalls) {
const id = call.id;
if (typeof id === "string" && id.length > 0) {
unknownWithId.push(call);
} else {
// eslint-disable-next-line no-console
console.warn(
`[emit_unknown_tools_notice] unknown tool_call '${call.name}' has no id; dropping from both errorToolMessages and strippedAIMessage.tool_calls to avoid emitting a ToolMessage with empty tool_call_id`,
);
}
}
// If every unknown call lacked an id, `unknownWithId` is empty and
// `errorToolMessages` below will be empty — the AIMessage retains
// only `knownCalls` and we still emit the notice in the pure-unknown
// case below (drop-only is still a reportable turn).
// Rebuild the prior AIMessage preserving its id + metadata, retaining
// BOTH knownCalls AND unknownWithId on `tool_calls`. Retaining the
// unknowns is what keeps the OpenAI transcript well-formed on the
// next user turn: every errorToolMessage below references an id
// that still appears on the immediately preceding AIMessage's
// `tool_calls`. Stripping the unknowns while still emitting their
// error ToolMessages produced orphaned `tool_call_id`s — OpenAI's
// chat completions API rejects a ToolMessage whose `tool_call_id`
// does not match a preceding AIMessage `tool_call.id`, so the next
// user turn failed. With the unknowns retained, the AIMessage →
// ToolMessage(result) pairing is intact for every unknown.
//
// Safety for the frontend: the frontend action handler only
// dispatches a tool_call when it receives a live TOOL_CALL_START /
// TOOL_CALL_END event stream during the current agent turn. This
// node DOES NOT emit those events — it only writes to state.messages
// after the model stream is already finalized. On the next turn,
// the frontend sees the pair (AIMessage.tool_call + ToolMessage
// result) already present in the snapshot; pairs that carry a
// result are treated as resolved and are not re-dispatched.
//
// Dropped-id unknowns (no usable tool_call_id) are still omitted
// from tool_calls — they have no matching ToolMessage, so keeping
// them would reintroduce the dangling-id problem on the next turn.
const retainedCalls: ToolCall[] = [...knownCalls, ...unknownWithId];
const strippedAIMessage = rebuildAIMessageWithToolCalls(
lastMessage,
retainedCalls,
);
const errorToolMessages = unknownWithId.map((call) => {
return new ToolMessage({
status: "error",
name: call.name,
// Narrowed: unknownWithId only contains calls whose id is a
// non-empty string.
tool_call_id: call.id as string,
content: `Tool '${call.name}' is not available in this environment.`,
});
});
// Mixed frontend-action + unknown batch: the surviving knownCalls
// are frontend-action calls that still need to reach the frontend
// runtime. Appending an `AIMessage(notice)` here would leave
// strippedAIMessage's frontend-action tool_calls with no matching
// ToolMessages before the trailing notice, producing an ill-formed
// OpenAI transcript on replay. Instead, suppress the notice in the
// mixed case and let the outgoing edge route the surviving calls
// through `restore_frontend_tools` → END for normal dispatch.
//
// Pure-unknown batch (`knownCalls.length === 0`): emit the notice
// as before so the turn doesn't end silently.
const unknownNames = unknownCalls.map((c) => c.name);
const trailingMessages: BaseMessage[] =
knownCalls.length === 0
? [
new AIMessage({
content: `I tried to call tools that aren't available in this environment (${unknownNames.join(
", ",
)}). Cancelling this turn.`,
}),
]
: [];
// Always clear any prior-turn intercept slot before routing to
// `restore_frontend_tools`. Gating the clear on `knownCalls.length === 0`
// is incorrect: in a MIXED batch (knownCalls.length > 0) the surviving
// frontend-action calls ride the stripped AIMessage to `restore_frontend_tools`,
// which consumes `interceptedToolCalls` + `originalAIMessageId`. If a stale
// stash from a PRIOR turn still sits in that slot, it would be grafted
// onto an unrelated AIMessage in THIS turn. Clearing unconditionally
// guarantees restore_frontend_tools only sees state stashed for the
// current turn (which, from this node, is always empty — no stash is
// written here).
const slotClear: Partial<AgentState> = {
interceptedToolCalls: undefined,
originalAIMessageId: undefined,
};
// Sequence on the channel (mixed case):
// [...existing..., strippedAIMessage (replaces lastMessage),
// ToolMessage(unknown1), ..., ToolMessage(unknownN)]
// Pure-unknown case appends a trailing AIMessage(notice).
return {
messages: [
...messages.slice(0, -1),
strippedAIMessage,
...errorToolMessages,
...trailingMessages,
],
...slotClear,
} as unknown as Partial<AgentState>;
}
const workflow = new StateGraph(AgentStateAnnotation)
.addNode("chat_node", chat_node)
.addNode("tool_node", new ToolNode(tools))
.addNode("intercept_frontend_tools", intercept_frontend_tools)
.addNode("restore_frontend_tools", restore_frontend_tools)
.addNode("emit_unknown_tools_notice", emit_unknown_tools_notice)
.addEdge(START, "chat_node")
.addEdge("intercept_frontend_tools", "tool_node")
.addEdge("tool_node", "chat_node")
.addEdge("restore_frontend_tools", END)
// Route through `restore_frontend_tools` (not END) so any prior
// unflushed intercept stash is cleared and any surviving
// frontend-action tool_calls retained on the stripped AIMessage in
// the mixed-batch case are reattached via the canonical restore
// path before termination. `restore_frontend_tools` no-ops when the
// slot is empty, so the pure-unknown case still terminates cleanly.
.addEdge("emit_unknown_tools_notice", "restore_frontend_tools")
.addConditionalEdges("chat_node", shouldContinue);
const memory = new MemorySaver();
export const graph = workflow.compile({
checkpointer: memory,
});
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "node16",
"resolvePackageJsonExports": true,
"resolvePackageJsonImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}
@@ -0,0 +1,18 @@
# LangGraph deployment URL (required in production, defaults to http://localhost:8125 in dev)
# LANGGRAPH_DEPLOYMENT_URL=
# LANGSMITH_* variables below are primarily consumed by the agent /
# LangGraph deployment at deploy time (set them in the agent's
# environment, e.g. the LangGraph Cloud deployment or `apps/agent/.env`).
# The Next.js web app itself only observes LANGSMITH_API_KEY here to
# emit a dev-only "missing key" warning and to forward the key through
# to the LangGraphAgent client in apps/web/src/app/api/copilotkit/route.ts.
# LangSmith API key (optional, for production tracing)
# LANGSMITH_API_KEY=
# Enable LangSmith tracing (optional)
# LANGSMITH_TRACING=true
# LangSmith project name (optional; used by LangSmith tracing if LANGSMITH_TRACING=true)
# LANGSMITH_PROJECT=interrupts-langgraph
@@ -0,0 +1,23 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
// Flat-config ESLint setup for Next 16 / React 19 / TS 5. Mirrors the
// sibling `examples/showcases/open-mcp-client/apps/web/eslint.config.mjs`
// so lint behaviour stays consistent across starters. Next 16 removed
// the `next lint` command, so package.json now invokes `eslint .`
// directly against this config.
const eslintConfig = [
...nextCoreWebVitals,
...nextTypescript,
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@copilotkit/runtime"],
};
export default nextConfig;
@@ -0,0 +1,42 @@
{
"name": "web-langgraph-interrupt",
"version": "0.1.7",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint . --max-warnings=0"
},
"dependencies": {
"@copilotkit/react-core": "workspace:*",
"@copilotkit/react-ui": "workspace:*",
"@copilotkit/runtime": "workspace:*",
"next": "16.0.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"shiki": "^3.22.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^22.19.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.8",
"tailwindcss": "^4",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"nx": {
"targets": {
"build": {
"cache": false
}
}
}
}
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1,28 @@
{
"name": "web-langgraph-interrupt",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/web/src",
"projectType": "application",
"targets": {
"dev": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter web-langgraph-interrupt dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "pnpm --filter web-langgraph-interrupt build"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter web-langgraph-interrupt lint"
}
}
},
"tags": []
}
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,149 @@
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import { NextRequest, NextResponse } from "next/server";
// 1. Runtime adapter. Since this starter wires agent responses directly
// (no OpenAI/Anthropic-compatible chat-completions fallback), we use
// the empty adapter: it satisfies the runtime interface without
// dispatching any LLM requests of its own.
const serviceAdapter = new ExperimentalEmptyAdapter();
// 2. LANGGRAPH_DEPLOYMENT_URL handling. In development we fall back to
// localhost:8125 (the port this starter's LangGraph dev server uses —
// see apps/agent/package.json) and warn so the developer sees it. In
// production a missing URL is almost always a misconfiguration — but
// we defer the hard failure to the first request (see POST below)
// rather than throwing at module load. `next build` evaluates route
// modules with NODE_ENV=production during route collection, while
// runtime-only env vars (Vercel secrets, Railway env, Docker ENV at
// container start) are NOT injected at build time. Throwing at module
// load would abort otherwise-valid production builds.
if (
!process.env.LANGGRAPH_DEPLOYMENT_URL &&
process.env.NODE_ENV !== "production"
) {
console.warn(
"[copilotkit/route] LANGGRAPH_DEPLOYMENT_URL is not set; falling back to http://localhost:8125. Set LANGGRAPH_DEPLOYMENT_URL in production.",
);
}
// LangSmith is optional; warn once at module load so a missing key
// surfaces in logs. When absent we omit the langsmithApiKey field
// entirely rather than passing "" — omitting the field disables
// LangSmith tracing cleanly; passing an empty string may be forwarded
// to the SDK. Gate the warn on NODE_ENV so it does not fire during
// `next build` in CI (same rationale as the DEPLOYMENT_URL warn above).
if (!process.env.LANGSMITH_API_KEY && process.env.NODE_ENV !== "production") {
console.warn(
"[copilotkit/route] LANGSMITH_API_KEY is not set; LangSmith tracing is disabled for this session.",
);
}
// 3. Lazy runtime construction. We build the LangGraphAgent + runtime +
// handler on the first request rather than at module load, so that
// `next build` (which evaluates this file with NODE_ENV=production but
// without runtime env vars) does not bake the fallback localhost URL
// into the compiled artifact. The cached closure keeps per-request
// overhead at the same single-allocation cost as the eager form.
// Matches the signature returned by copilotRuntimeNextJSAppRouterEndpoint:
// `(req: Request) => Response | Promise<Response>`. NextRequest extends
// Request, so the POST handler below can still pass its req through
// without an explicit cast.
let cachedHandleRequest:
| ((req: Request) => Response | Promise<Response>)
| null = null;
const getHandleRequest = () => {
if (cachedHandleRequest) return cachedHandleRequest;
const deploymentUrl = process.env.LANGGRAPH_DEPLOYMENT_URL;
if (!deploymentUrl && process.env.NODE_ENV === "production") {
throw new Error(
"[copilotkit/route] LANGGRAPH_DEPLOYMENT_URL is required in production. " +
"Set it to the deployed LangGraph endpoint for this starter.",
);
}
const agent = new LangGraphAgent({
deploymentUrl: deploymentUrl || "http://localhost:8125",
graphId: "default",
// Only pass langsmithApiKey when it's a non-empty string; omitting
// the field disables LangSmith tracing without asking the SDK to
// authenticate with an empty key.
...(process.env.LANGSMITH_API_KEY
? { langsmithApiKey: process.env.LANGSMITH_API_KEY }
: {}),
});
// Register the single LangGraph agent under the `default` name, which
// matches the <CopilotKit agent="default"> prop in layout.tsx. If you
// need to expose this agent under an additional id (e.g. when pointing
// a second frontend at this runtime), add another entry here and
// update the corresponding <CopilotKit agent="..."> prop.
const runtime = new CopilotRuntime({
agents: {
default: agent,
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
cachedHandleRequest = handleRequest;
return handleRequest;
};
// 4. POST handler. Resolves the cached handler (building it on first
// request), then dispatches. Wrap in try/catch so unhandled exceptions
// surface as a structured 500 rather than a raw Next.js error page,
// and log the failure for observability. Errors inside the streaming
// response are handled by the runtime itself.
export const POST = async (req: NextRequest) => {
// Redact `detail` in production: raw error messages can leak
// internals (stack-adjacent strings, paths, env-var names). Keep
// the full detail in non-production builds so developers see the
// real cause locally.
const isProd = process.env.NODE_ENV === "production";
// Split construction vs dispatch into two try/catch regions so
// logs distinguish runtime-construction failures (bad config / env)
// from dispatch failures inside handleRequest. Response shape is
// unchanged between the two.
let handleRequest: (req: Request) => Response | Promise<Response>;
try {
handleRequest = getHandleRequest();
} catch (err) {
console.error("[copilotkit/route] runtime construction failed:", err);
return NextResponse.json(
{
error: "Internal error while dispatching CopilotKit request.",
...(isProd
? {}
: { detail: err instanceof Error ? err.message : String(err) }),
},
{ status: 500 },
);
}
try {
return await handleRequest(req);
} catch (err) {
console.error("[copilotkit/route] handleRequest dispatch failed:", err);
return NextResponse.json(
{
error: "Internal error while dispatching CopilotKit request.",
...(isProd
? {}
: { detail: err instanceof Error ? err.message : String(err) }),
},
{ status: 500 },
);
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,24 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
body,
html {
height: 100%;
}
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { CopilotKit } from "@copilotkit/react-core";
import "./globals.css";
import "@copilotkit/react-ui/styles.css";
export const metadata: Metadata = {
title: "CopilotKit LangGraph Interrupts Starter",
description:
"A starter template for building AI agents with human-in-the-loop interrupts using LangGraph and CopilotKit.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={"antialiased"}>
<CopilotKit runtimeUrl="/api/copilotkit" agent="default">
{children}
</CopilotKit>
</body>
</html>
);
}
@@ -0,0 +1,398 @@
"use client";
import { useCoAgent, useCopilotAction } from "@copilotkit/react-core";
import { CopilotKitCSSProperties, CopilotSidebar } from "@copilotkit/react-ui";
import { useInterrupt } from "@copilotkit/react-core/v2";
import { useState } from "react";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
// 🪁 Frontend Actions: https://docs.copilotkit.ai/guides/frontend-actions
useCopilotAction(
{
name: "setThemeColor",
description: "Set the theme color of the page.",
parameters: [
{
name: "themeColor",
type: "string",
description: "The theme color to set. Make sure to pick nice colors.",
required: true,
},
],
handler({ themeColor }) {
// Defensive guard: during streaming, the LLM may invoke the handler
// before the `themeColor` arg has fully arrived. Skipping the update
// here avoids writing `undefined` into the CSS custom property, which
// would collapse the --copilot-kit-primary-color variable.
if (typeof themeColor !== "string" || themeColor.length === 0) return;
setThemeColor(themeColor);
},
},
[setThemeColor],
);
// 🪁 Interrupts: Handle human-in-the-loop confirmations from the agent
// https://docs.copilotkit.ai/coagents/human-in-the-loop (useInterrupt)
//
// The agent emits interrupt payloads shaped as
// { action: "delete_proverb"; proverb: string; message: string }
// Today the only supported action is delete_proverb. When future
// actions are added, widen InterruptPayload to a discriminated union
// on `action` and extend APPROVE_LABELS below — TypeScript will flag
// the missing key as a compile error because APPROVE_LABELS is typed
// as Record<InterruptPayload["action"], string>.
useInterrupt({
render: ({ event, resolve }) => {
const parsed = parseInterruptPayload(event.value);
if (!parsed.ok) {
// Unknown/malformed payload shape. Surface a generic fallback
// rather than crashing on an unchecked cast. Resolving with a
// cancellation unblocks the agent in case the user dismisses it.
// Log the reason + raw payload so developers can diagnose
// schema drift from the agent side — the UI shows "unknown"
// without this, which is useless for debugging. The parser
// itself does not log, so this is the single log line for
// the whole failure path.
console.error(
"[interrupts-langgraph] Unknown interrupt payload shape:",
parsed.reason,
event.value,
);
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 my-2">
<p className="text-sm text-red-800">
Received an unknown interrupt payload. This will tell the agent to
cancel.
</p>
<button
onClick={() => resolve({ approved: false })}
className="mt-2 px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel request
</button>
</div>
);
}
// Resolve the button label for this action.
const payload = parsed.value;
const approveLabel = APPROVE_LABELS[payload.action];
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 my-2">
<p className="text-sm font-medium text-yellow-800 mb-1">
Confirmation Required
</p>
<p className="text-sm text-yellow-700 mb-3">{payload.message}</p>
<div className="flex gap-2">
<button
onClick={() => resolve({ approved: true })}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
{approveLabel}
</button>
<button
onClick={() => resolve({ approved: false })}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Cancel
</button>
</div>
</div>
);
},
});
return (
<main
style={
{ "--copilot-kit-primary-color": themeColor } as CopilotKitCSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
clickOutsideToClose={false}
defaultOpen={true}
labels={{
title: "Popup Assistant",
initial:
'👋 Hi, there! You\'re chatting with an agent. This agent comes with a few tools to get you started.\n\nFor example you can try:\n- **Frontend Tools**: "Set the theme to orange"\n- **Shared State**: "Write a proverb about AI"\n- **Generative UI**: "Get the weather in SF"\n- **Interrupts**: "Delete the first proverb" (will ask for confirmation)\n\nAs you interact with the agent, you\'ll see the UI update in real-time to reflect the agent\'s **state**, **tool calls**, and **progress**.',
}}
/>
</main>
);
}
// Partial view of the agent state the UI reads + writes. The agent's
// full state is defined in apps/agent/src/agent.ts (AgentStateAnnotation);
// this type intentionally declares only the subset the UI consumes. Keep
// the field names and types in sync with the agent side.
type AgentState = {
proverbs: string[];
};
// Shape of interrupt payloads produced by deleteProverb on the agent
// side. Validated at runtime by `parseInterruptPayload` below. Kept
// close to the consumer so divergence surfaces in the renderer where
// it's used.
type InterruptPayload = {
action: "delete_proverb";
proverb: string;
message: string;
};
// Approve-button label per interrupt action. Typed exhaustively against
// InterruptPayload["action"] so adding a new action anywhere else in the
// file is a compile error until this map is extended.
const APPROVE_LABELS: Record<InterruptPayload["action"], string> = {
delete_proverb: "Yes, delete it",
};
// Result type surfaces the first failed predicate as a stable reason
// string so the caller can log a single message containing both the
// reason and the raw value. The parser itself does no logging — the
// renderer owns the single log line (avoids the prior double-log).
type ParseResult =
| { ok: true; value: InterruptPayload }
| { ok: false; reason: string };
function parseInterruptPayload(value: unknown): ParseResult {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return { ok: false, reason: "payload is not object" };
}
const v = value as Record<string, unknown>;
if (v.action !== "delete_proverb") {
return { ok: false, reason: "action mismatch" };
}
if (typeof v.proverb !== "string") {
return { ok: false, reason: "proverb not string" };
}
if (typeof v.message !== "string") {
return { ok: false, reason: "message not string" };
}
return {
ok: true,
value: {
action: "delete_proverb",
proverb: v.proverb,
message: v.message,
},
};
}
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
const { state, setState } = useCoAgent<AgentState>({
name: "default",
initialState: {
proverbs: [
"CopilotKit may be new, but it's the best thing since sliced bread.",
],
},
});
// Defensive default: during transient state-sync, `state` or
// `state.proverbs` can momentarily be undefined. Coalescing here
// keeps the map/length checks below from falling into the
// `undefined !== 0` trap that would hide the "No proverbs yet"
// empty-state fallback.
const proverbs = state?.proverbs ?? [];
// 🪁 Shared State action: writes into the shared agent state above
// (not to be confused with a pure frontend action — this mutates
// `proverbs`, which is part of the agent's CoAgent state and is
// synced back to the graph on the next turn).
// https://docs.copilotkit.ai/coagents/shared-state
useCopilotAction(
{
name: "addProverb",
description: "Add a proverb to the list.",
parameters: [
{
name: "proverb",
type: "string",
description: "The proverb to add. Make it witty, short and concise.",
required: true,
},
],
handler: ({ proverb }) => {
// Defensive guard: during streaming, the LLM may invoke the handler
// before the `proverb` arg has fully arrived. Skipping the update
// here avoids pushing `undefined` into the proverbs array, which
// would break React rendering and React key stability.
if (typeof proverb !== "string" || proverb.length === 0) return;
setState((prevState) => ({
...prevState,
proverbs: [...(prevState?.proverbs || []), proverb],
}));
},
},
[setState],
);
//🪁 Generative UI: https://docs.copilotkit.ai/coagents/generative-ui
//
// `available: "disabled"` is the correct pairing with `render:` here.
// In @copilotkit/react-core, useCopilotAction routes based on the
// `available` value (see
// node_modules/@copilotkit/react-core/src/hooks/use-copilot-action.ts
// `getActionConfig`):
// - "enabled" / "remote" → frontend tool (handler runs client-side)
// - "frontend" / "disabled" → render-only (registers a tool-call
// renderer; no handler)
// We want the BACKEND `getWeather` tool in agent.ts to execute the
// handler server-side AND have the streamed tool-call args drive a
// client-side gen-UI render here. The render-only path (via
// useRenderToolCall) still adds this renderer to
// `copilotkit.renderToolCalls` regardless of `available`, so the
// WeatherCard fires during tool-call streaming (see
// examples/showcases/scene-creator/src/app/page.tsx for the same
// pattern). Setting `"enabled"` here would register a FRONTEND handler
// of the same name and collide with the backend tool.
useCopilotAction(
{
name: "getWeather",
description: "Get the weather for a given location.",
available: "disabled",
parameters: [{ name: "location", type: "string", required: true }],
render: ({ args }) => {
return <WeatherCard location={args.location} themeColor={themeColor} />;
},
},
[themeColor],
);
return (
<div
style={{ backgroundColor: themeColor }}
className="h-screen w-screen flex justify-center items-center flex-col transition-colors duration-300"
>
<div className="bg-white/20 backdrop-blur-md p-8 rounded-2xl shadow-xl max-w-2xl w-full">
<h1 className="text-4xl font-bold text-white mb-2 text-center">
Proverbs
</h1>
<p className="text-gray-200 text-center italic mb-6">
This is a demonstrative page, but it could be anything you want! 🪁
</p>
<hr className="border-white/20 my-6" />
<div className="flex flex-col gap-3">
{proverbs.map((proverb, index) => (
// Intentional index-and-content composite key: `${index}-${proverb}`.
// Plain `proverb` collides on duplicates (React logs a
// duplicate-key warning and collapses to one node); plain
// `index` destabilizes rows across agent-side inserts/deletes.
// The composite tolerates duplicates for this demo where rows
// are append-only and reordering is not a concern. Migrating
// to `{id, text}` objects (seeded with crypto.randomUUID())
// is the proper fix and is deferred to a follow-up since it
// requires widening AgentState.proverbs.
<div
key={`${index}-${proverb}`}
className="bg-white/15 p-4 rounded-xl text-white relative group hover:bg-white/20 transition-all"
>
<p className="pr-8">{proverb}</p>
<button
onClick={() =>
setState((prev) => ({
...(prev ?? {}),
// Filter by value identity rather than captured index:
// between render and click, the agent may have
// inserted/removed proverbs, shifting `index` to point
// at a different entry. Matching on the string value
// is stable (and consistent with the React key above).
proverbs: (prev?.proverbs ?? []).filter(
(p) => p !== proverb,
),
}))
}
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 transition-opacity
bg-red-500 hover:bg-red-600 text-white rounded-full h-6 w-6 flex items-center justify-center"
>
</button>
</div>
))}
</div>
{proverbs.length === 0 && (
<p className="text-center text-white/80 italic my-8">
No proverbs yet. Ask the assistant to add some!
</p>
)}
</div>
</div>
);
}
function SunIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-14 h-14 text-yellow-200"
>
<circle cx="12" cy="12" r="5" />
<path
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
strokeWidth="2"
stroke="currentColor"
/>
</svg>
);
}
// Weather card rendered by the getWeather action. `location` and
// `themeColor` are driven by the agent's tool-call args + frontend state.
function WeatherCard({
location,
themeColor,
}: {
location?: string;
themeColor: string;
}) {
return (
<div
style={{ backgroundColor: themeColor }}
className="rounded-xl shadow-xl mt-6 mb-4 max-w-md w-full"
>
<div className="bg-white/20 p-4 w-full">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-white capitalize">
{/* During streaming, the tool-call arg may not have arrived
yet, leaving `location` undefined and producing a blank
heading. Show a loading placeholder in that window. */}
{location || "Loading…"}
</h3>
<p className="text-white">Current Weather</p>
</div>
<SunIcon />
</div>
<div className="mt-4 flex items-end justify-between">
<div className="text-3xl font-bold text-white">70°</div>
<div className="text-sm text-white">Clear skies</div>
</div>
<div className="mt-4 pt-4 border-t border-white">
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<p className="text-white text-xs">Humidity</p>
<p className="text-white font-medium">45%</p>
</div>
<div>
<p className="text-white text-xs">Wind</p>
<p className="text-white font-medium">5 mph</p>
</div>
<div>
<p className="text-white text-xs">Feels Like</p>
<p className="text-white font-medium">72°</p>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
@@ -0,0 +1,21 @@
{
"name": "langgraph-js-starter",
"version": "0.1.7",
"private": true,
"workspaces": [
"apps/*"
],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint"
},
"devDependencies": {
"@langchain/langgraph-cli": "^1.0.4",
"turbo": "^2.3.3"
},
"engines": {
"node": ">=20.0.0"
},
"packageManager": "pnpm@9.15.0"
}
@@ -0,0 +1,2 @@
packages:
- "apps/*"
@@ -0,0 +1,19 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"dependsOn": ["^build"]
},
"start": {
"cache": false,
"persistent": true,
"dependsOn": ["^build"]
},
"lint": {}
}
}