Next.js
First-class Next.js integration with streaming API route handlers and React hooks for building chat interfaces.
Overview
The @cogitator-ai/next package provides everything you need to build AI-powered Next.js applications: server-side route handlers that stream responses via SSE, and client-side React hooks that consume them with full tool call support.
pnpm add @cogitator-ai/next @cogitator-ai/coreServer: Chat Handler
createChatHandler turns a Cogitator agent into a streaming API route handler. It accepts a standard Request, runs the agent with token streaming enabled, and returns an SSE stream of typed events (start, text-*, reasoning-*, tool-call-*, tool-result, approval-required, finish, error) ending with data: [DONE]. The events are the server adapters' streaming protocol, with the threadId added to finish — consume them with useCogitatorChat. finish carries the run's usage, the same object createAgentHandler answers: inputTokens, outputTokens, totalTokens, and reasoningTokens, cachedInputTokens and cacheWriteTokens when the model reported them. The handlers are plain (req: Request) => Promise<Response> functions, so they also work in other fetch-based runtimes.
The default body is { messages, threadId?, metadata? }.
- The last user message is the run input; history lives in the thread (configure
memoryonCogitator). Thefinishevent carries thethreadId, anduseCogitatorChatadopts it automatically. - Request
metadatais passed to the run ascontext. - The run is aborted when the client disconnects.
- An agent with
reasoning: { summary: true }streams its summary asreasoning-start/reasoning-delta/reasoning-end; a reasoning or text block is closed before the other kind, a tool call orfinishstarts. - Bodies over 1 MB get
413; malformed bodies or a request without a user message get400. - A failed run ends the stream with an
errorevent — see Errors.
import { Cogitator, Agent } from '@cogitator-ai/core';
import { createChatHandler } from '@cogitator-ai/next';
const cog = new Cogitator({
llm: {
defaultProvider: 'openai',
providers: { openai: { apiKey: process.env.OPENAI_API_KEY! } },
},
});
const agent = new Agent({
name: 'assistant',
model: 'openai/gpt-6.1-sol',
instructions: 'You are a helpful assistant.',
});
const handler = createChatHandler(cog, agent);
export const POST = handler;ChatHandlerOptions
You can pass options to customize parsing, authentication, and post-processing:
const handler = createChatHandler(cog, agent, {
beforeRun: async (req, input) => {
const token = req.headers.get('authorization');
if (!token) throw new Error('Unauthorized');
const user = await verifyToken(token);
return { userId: user.id };
},
afterRun: async (result) => {
await db.saveChatMessage(result.threadId, result.output);
},
});| Option | Type | Description |
|---|---|---|
parseInput | (req: Request) => Promise<ChatInput> | Custom request body parser |
beforeRun | (req: Request, input: ChatInput) => Promise<object | void> | Auth hook; the returned object is merged into the run options (userId, threadId, ...) but cannot replace stream, signal or the streaming callbacks. Throw an error with a status property to reject with that status (default 401) |
afterRun | (result: RunResult) => Promise<void> | Post-processing hook (save to DB, log, etc.). Runs before finish; a failure is reported as an error event |
Server: Agent Handler
For non-streaming use cases, createAgentHandler returns a JSON response with the full result including tool calls, traces, and usage. The default parser requires a non-blank string input (plus optional object context and string threadId) and answers 400 otherwise:
import { createAgentHandler } from '@cogitator-ai/next';
const handler = createAgentHandler(cog, agent, {
beforeRun: async (req, input) => {
return { userId: 'user-123' };
},
});
export const POST = handler;The response shape:
interface AgentResponse {
output: string;
threadId: string;
usage: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
reasoningTokens?: number;
cachedInputTokens?: number;
cacheWriteTokens?: number;
};
toolCalls: ToolCall[];
trace?: { traceId: string; spans: unknown[] };
reasoning?: string;
status?: 'completed' | 'paused';
pendingApprovals?: PendingApproval[];
}Errors
Every handler answers request problems before running the agent, as JSON { "error": "<message>" }:
413for bodies over 1 MB,400for invalid JSON, a body that fails the default parser, or an error thrown byparseInput.- An error thrown by
beforeRunuses itsstatusproperty (400–599) and defaults to401, with the error's message.
A failed run answers { "error": "<message>", "code": "<code>" } — or, in a stream, an error event with the same message and code. A CogitatorError keeps its message, code and status (for example 403 THREAD_ACCESS_DENIED, 429 LLM_RATE_LIMITED); any other error is logged on the server and reported as 500 with Internal server error and code INTERNAL_ERROR, so its text never reaches the client.
Approvals
A tool with requiresApproval pauses the run before it executes. The runtime keeps the checkpoint on the server, per thread; clients only see the calls waiting for a decision (toolCallId, toolName, arguments, description, sideEffects). createAgentHandler answers with status: 'paused' and pendingApprovals, and createChatHandler streams { type: 'approval-required', threadId, approvals } right before finish.
createResumeHandler continues the run. Its body is { threadId, decisions?, defaultDecision? }, a decision being { approved: true } or { approved: false, reason? } by tool call id. Approved calls run, declined ones answer the model with the reason, and undecided ones pause the run again:
import { createResumeHandler } from '@cogitator-ai/next';
export const POST = createResumeHandler(cog, agent, {
stream: true,
beforeRun: async (req) => ({ userId: await getUserId(req) }),
});It answers like createAgentHandler, or streams like createChatHandler with stream: true. Return the caller's userId from beforeRun: only the user the run belongs to may resume it (403 THREAD_ACCESS_DENIED), and a thread without a paused run answers 409 RUN_NOT_PAUSED.
const { pendingApprovals, approve, deny, resume } = useCogitatorChat({
api: '/api/chat',
resumeApi: '/api/chat/resume',
onApprovalRequired: (approvals) => console.log('Waiting for', approvals),
});
return (
<>
{pendingApprovals.map((call) => (
<p key={call.toolCallId}>
{call.toolName}: {JSON.stringify(call.arguments)}
<button onClick={() => resume({ decisions: { [call.toolCallId]: { approved: true } } })}>
Approve this one
</button>
</p>
))}
<button onClick={() => approve()}>Approve all</button>
<button onClick={() => deny('Over the refund limit')}>Deny all</button>
</>
);The rest of the run streams into a new assistant message (a JSON resumeApi is appended as one message). pendingApprovals clears once the resume is accepted, when the server has nothing paused anymore (409), or when the user sends a new message, which declines the waiting calls. useCogitatorAgent({ api, resumeApi }) exposes the pendingApprovals of its last result and resume(decisions).
Client: useCogitatorChat
The useCogitatorChat hook manages the entire chat lifecycle -- sending messages, processing the SSE stream, handling tool calls, and tracking loading state.
'use client';
import { useCogitatorChat } from '@cogitator-ai/next/client';
export function Chat() {
const { messages, input, setInput, send, isLoading, stop, reload, error } = useCogitatorChat({
api: '/api/chat',
onToolCall: (toolCall) => {
console.log(`Tool invoked: ${toolCall.name}`, toolCall.arguments);
},
onFinish: (message) => {
console.log('Assistant replied:', message.content);
},
retry: { maxRetries: 2, backoff: 'exponential' },
});
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong> {msg.content}
</div>
))}
{error && <p>Error: {error.message}</p>}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
disabled={isLoading}
/>
{isLoading && <button onClick={stop}>Stop</button>}
</div>
);
}UseChatOptions
| Option | Type | Description |
|---|---|---|
api | string | API endpoint URL (required) |
threadId | string | Initial thread ID for conversation context |
initialMessages | ChatMessage[] | Pre-populate the message list |
headers | Record<string, string> | Custom headers sent with each request |
onError | (error: Error) => void | Error callback |
onFinish | (message: ChatMessage) => void | Called when assistant message is complete (not after an error event) |
onToolCall | (toolCall: ToolCall) => void | Called when a tool is invoked |
onToolResult | (result: ToolResultEvent) => void | Called when a tool returns a result |
onReasoning | (delta: string) => void | Called with each piece of the reasoning summary; the pieces collect in message.reasoning |
onApprovalRequired | (approvals: PendingApproval[]) => void | Called when the run pauses for approvals |
resumeApi | string | Endpoint of a createResumeHandler, for resume, approve and deny |
retry | RetryConfig | Retry on network failures and 408/429/502/503/504 |
Return Values
| Field | Type | Description |
|---|---|---|
messages | ChatMessage[] | All messages including live streaming |
input | string | Current input value |
setInput | (value: string) => void | Update input |
send | (input?, metadata?) => Promise<void> | Send a message (input defaults to the input field) |
isLoading | boolean | Whether agent is running |
error | Error | null | Last error |
stop | () => void | Abort the current stream (the partial reply is kept) |
reload | () => Promise<void> | Re-send the last user message |
threadId | string | undefined | Current thread ID |
setThreadId | (id: string) => void | Switch conversation thread |
appendMessage | (msg: ChatMessage) => void | Add a message to the list |
clearMessages | () => void | Reset conversation |
setMessages | (msgs: ChatMessage[]) => void | Replace message list |
pendingApprovals | PendingApproval[] | Tool calls the paused run waits on |
resume | (decisions: ResumeDecisions) => Promise<void> | Continue the paused run with these decisions |
approve | () => Promise<void> | Approve every pending call |
deny | (reason?: string) => Promise<void> | Decline every pending call |
HTTP failures surface as HttpError (exported from @cogitator-ai/next/client) with a status field and a message such as Request failed: 400 - No user message provided.
Client: useCogitatorAgent
For non-streaming agent calls (task execution, one-shot questions), use useCogitatorAgent:
'use client';
import { useCogitatorAgent } from '@cogitator-ai/next/client';
export function AgentRunner() {
const { run, result, isLoading, error, reset } = useCogitatorAgent({
api: '/api/agent',
onSuccess: (res) => console.log('Done:', res.output),
});
return (
<div>
<button onClick={() => run({ input: "Summarize today's news" })} disabled={isLoading}>
Run Agent
</button>
{result && <pre>{JSON.stringify(result, null, 2)}</pre>}
</div>
);
}| Option | Type | Description |
|---|---|---|
api | string | Endpoint of a createAgentHandler (required) |
resumeApi | string | Endpoint of a createResumeHandler, for resume |
headers | Record<string, string> | Custom headers sent with each request |
onSuccess | (result: AgentResponse) => void | Called with each successful result |
onError | (error: Error) => void | Error callback |
retry | RetryConfig | Retry on network failures and 408/429/502/503/504 |
It returns run(input), result, reasoning, pendingApprovals, resume(decisions), isLoading, error and reset().
createAgentHandler returns the run's reasoning summary as reasoning (also exposed by the hook as reasoning), and usage carries reasoningTokens, cachedInputTokens and cacheWriteTokens when the provider reports them. Starting a new run() aborts the previous request, and reset() cancels the in-flight one.
Server Actions
You can also use Cogitator directly inside Next.js Server Actions without the HTTP layer:
'use server';
import { Cogitator, Agent } from '@cogitator-ai/core';
const cog = new Cogitator({/* ... */});
const agent = new Agent({
name: 'summarizer',
model: 'openai/gpt-6-luna',
instructions: 'Summarize the given text concisely.',
});
export async function summarize(text: string) {
const result = await cog.run(agent, { input: text });
return { summary: result.output, usage: result.usage };
}