CopilotKit + LangGraph.js HITL Integration Guide
Overview
This document summarizes our experience integrating CopilotKit with LangGraph.js for Human-in-the-Loop (HITL) workflows. It covers critical bugs, failed approaches, and the final working solution.
Tech Stack:
- Frontend: Next.js 16, React 19, CopilotKit 1.x
- AI Runtime: CopilotKit SDK
- AI Agent: LangGraph.js 1.0
- Deployment: Railway (frontend + agent via private network)
The Problem
When implementing HITL approval flows (e.g., user approves AI-generated content before proceeding), we encountered a critical incompatibility between CopilotKit and LangGraph.js.
Symptom
ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["toolCallId"],
"message": "Required"
}
]
Root Cause
Field naming mismatch between LangGraph.js and CopilotKit:
| Framework | Field Name | Format |
|---|---|---|
| LangGraph.js / OpenAI | tool_call_id | snake_case |
| CopilotKit | toolCallId | camelCase |
When LangGraph.js creates a ToolMessage, it uses tool_call_id. When CopilotKit tries to parse the message, it expects toolCallId and fails with ZodError.
Failed Approaches
Approach 1: Add toolCallId dynamically
const toolMessage = new ToolMessage({
content: result,
name: toolName,
tool_call_id: toolCall.id!,
});
// Try to add CopilotKit-compatible field
(toolMessage as any).toolCallId = toolCall.id!;Result: FAILED
LangChain’s serialization mechanism ignores dynamically added properties. When the message is serialized for storage/transmission, only declared class fields are included.
Approach 2: Use emitMessages: false
const customConfig = copilotkitCustomizeConfig(config, {
emitMessages: false // Don't emit ToolMessage to frontend
});Result: FAILED
emitMessages: false only prevents messages from being streamed during execution. The ToolMessage is still stored in LangGraph’s checkpointer and returned when CopilotKit fetches the state later.
Approach 3: Route back to chatNode after tool execution
function routeAfterTool(state) {
if (toolCall.name === "generate_outline") {
return "chat_node"; // Generate AIMessage to "consume" ToolMessage
}
return END;
}Result: FAILED
The ToolMessage still exists in the conversation history. When CopilotKit reconstructs the history for subsequent requests, it parses all messages including the ToolMessage → ZodError.
Approach 4: State-only approach (no messages)
// Don't add any messages, only emit state
if (toolName === "generate_outline") {
pendingContent = { type: "outline", content: result };
// NO resultMessages.push() - avoid ToolMessage entirely
}
await copilotkitEmitState(config, { ...state, pendingContent });
return { pendingContent }; // No messagesResult: FAILED
OpenAI’s API requires that every tool_call must have a corresponding ToolMessage response. Without the ToolMessage, subsequent API calls fail:
Error: 400 An assistant message with 'tool_calls' must be followed by
tool messages responding to each 'tool_call_id'.
The Working Solution: Dedicated Graph Node
The solution is to completely bypass tool calling for HITL operations by using a dedicated graph node instead of a tool.
Architecture Comparison
Before (Tool-based - BROKEN):
User Request → chatNode → LLM calls tool → toolNode → ToolMessage → ZodError
After (Node-based - WORKS):
User Request → routeFromStart → isOutlineRequest? → outline_node → AIMessage → Success
Implementation
1. Create a detection function
function isOutlineRequest(state: FanficAgentState): boolean {
// Check if we're in the outline step
const context = extractContextFromReadable(state);
const currentStep = state.wizardSession?.step || context?.step;
if (currentStep !== "outline") {
return false;
}
// Check if the message contains outline-related keywords
const lastMessage = state.messages[state.messages.length - 1];
if (!lastMessage || lastMessage._getType() !== "human") {
return false;
}
const content = typeof lastMessage.content === "string"
? lastMessage.content.toLowerCase()
: "";
const outlinePatterns = [
/write|create|make|generate|outline|story/i,
/romance|adventure|action|drama/i,
];
return outlinePatterns.some(p => p.test(content));
}2. Create a dedicated node
async function outlineNode(
state: FanficAgentState,
config: RunnableConfig
): Promise<Partial<FanficAgentState>> {
// Extract context from CopilotKit readable state
const context = extractContextFromReadable(state);
const sourceName = context?.sourceName || "Unknown";
const characters = context?.characters || [];
// Generate outline directly with LLM (no tool calling)
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
const response = await model.invoke([
new SystemMessage(buildOutlinePrompt(sourceName, characters)),
new HumanMessage(userRequest),
]);
const outlineContent = response.content as string;
// Set pendingContent for HITL - frontend will detect this
const pendingContent = {
type: "outline" as const,
content: outlineContent,
};
// Emit state for frontend HITL detection
await copilotkitEmitState(config, { ...state, pendingContent });
// Return AIMessage (NOT ToolMessage) - CopilotKit compatible
return {
messages: [new AIMessage({
content: "I've created your story outline! Please review it above.",
})],
pendingContent,
};
}3. Update routing from START
function routeFromStart(state: FanficAgentState): string {
// Check for research request first
if (isResearchRequest(state)) {
return "research_node";
}
// Check for outline request
if (isOutlineRequest(state)) {
return "outline_node"; // Skip chat_node entirely
}
// Default to chat node
return "chat_node";
}4. Add node to graph
const workflow = new StateGraph(FanficAgentStateAnnotation)
.addNode("chat_node", chatNode)
.addNode("tool_node", toolNode)
.addNode("research_node", researchNode)
.addNode("outline_node", outlineNode) // NEW
.addConditionalEdges(START, routeFromStart)
.addConditionalEdges("chat_node", routeAfterChat)
.addConditionalEdges("tool_node", routeAfterTool)
.addConditionalEdges("research_node", routeAfterResearch)
.addConditionalEdges("outline_node", routeAfterOutline); // NEW5. Remove tool from available tools
// tools/index.ts
// Exclude generateOutlineTool - handled by outline_node
export const allBackendTools = [
continueStoryTool,
expandSceneTool,
polishProseTool,
...characterTools,
...imageTools,
// generateOutlineTool - REMOVED
];6. Frontend: Detect pendingContent with useCoAgentStateRender
// wizard/page.tsx
useCoAgentStateRender<{
pendingContent?: { type: string; content: string } | null;
}>({
name: "fanfic_agent",
render: ({ state }) => {
if (state?.pendingContent?.type === "outline" && state.pendingContent.content) {
return (
<OutlineApprovalCard
outline={state.pendingContent.content}
onApprove={() => handleOutlineApproved(state.pendingContent!.content)}
onReject={(feedback) => console.log("Rejected:", feedback)}
onEdit={(editedOutline) => handleOutlineApproved(editedOutline)}
/>
);
}
return null;
},
});Key Takeaways
1. Avoid ToolMessage for HITL Operations
Rule: If an operation requires human approval before proceeding, do NOT implement it as a tool. Use a dedicated graph node instead.
Why: ToolMessage format is incompatible between LangGraph.js and CopilotKit. This is a known bug (see CopilotKit issue #2897).
2. Use State Emission for HITL Detection
Instead of relying on CopilotKit actions (useCopilotAction with renderAndWaitForResponse), use:
copilotkitEmitState()on the backend to emit state withpendingContentuseCoAgentStateRender()on the frontend to detect and render HITL UI
3. Pattern for Dedicated Nodes
When creating a dedicated node for HITL operations:
async function myHITLNode(state, config) {
// 1. Extract context from CopilotKit readable state
const context = extractContextFromReadable(state);
// 2. Perform the operation directly (no tool calling)
const result = await performOperation(context);
// 3. Set pendingContent for frontend detection
const pendingContent = { type: "myType", content: result };
// 4. Emit state to frontend
await copilotkitEmitState(config, { ...state, pendingContent });
// 5. Return AIMessage (not ToolMessage)
return {
messages: [new AIMessage({ content: "Please review above." })],
pendingContent,
};
}4. CopilotKit Readable Context Format
CopilotKit’s useCopilotReadable stringifies values with JSON.stringify(). On the backend, you must parse them:
function extractContextFromReadable(state) {
const copilotState = state.copilotkit;
if (copilotState?.context?.length > 0) {
const contextItem = copilotState.context[0];
// Value is a JSON string, not an object
if (typeof contextItem.value === "string") {
try {
return JSON.parse(contextItem.value);
} catch {
return null;
}
}
}
return null;
}5. Graph Routing Pattern
For HITL operations, route from START directly to the dedicated node:
function routeFromStart(state) {
if (isResearchRequest(state)) return "research_node";
if (isOutlineRequest(state)) return "outline_node";
if (isImageRequest(state)) return "image_node";
return "chat_node"; // Default for regular conversation
}Summary Table
| Operation Type | Implementation | Message Type | HITL Detection |
|---|---|---|---|
| Regular chat | chat_node | AIMessage | N/A |
| Tool execution | tool_node | ToolMessage | ❌ Avoid for HITL |
| Research | research_node | AIMessage | useCoAgentStateRender |
| Outline | outline_node | AIMessage | useCoAgentStateRender |
| Image generation | image_node | AIMessage | useCoAgentStateRender |
Related Issues
- CopilotKit Issue #2897: ToolMessage format mismatch with LangGraph.js
- LangChain serialization: Dynamic properties are ignored
Files Modified in This Solution
| File | Purpose |
|---|---|
src/agent/agent.ts | Add isOutlineRequest(), outlineNode(), update routing |
src/agent/tools/index.ts | Remove generateOutlineTool from exports |
src/app/(main)/(protected)/wizard/page.tsx | Add useCoAgentStateRender for HITL |
src/components/hitl/OutlineApprovalCard.tsx | HITL approval UI component |
Conclusion
The CopilotKit + LangGraph.js integration has a critical ToolMessage format incompatibility. The solution is to avoid ToolMessage entirely for HITL operations by using dedicated graph nodes that:
- Route directly from START (bypass chat_node)
- Perform operations with direct LLM calls (no tool calling)
- Emit state with
pendingContentfor frontend detection - Return AIMessage (not ToolMessage)
This pattern has been successfully tested and deployed in production.