How do I build a streaming /api/chat endpoint with opencode SDK?
say i want to create an /api/chat endpoint, towards which i can send messages and it (ideally streams) sends me responses? how do i build that with opencode SDK? imagine i'm on next.js and have an opencode SDK server already running at https://my-opencode-server.com
Now I have all the information needed. Let me provide you a comprehensive answer with code examples.
##Building a /api/chat Endpoint with OpenCode SDK in Next.js
The OpenCode SDK provides a way to create chat sessions and stream AI responses via Server-Sent Events (SSE). Here's how to build this:
###Architecture Overview
The pattern is:
- Send a prompt via
client.session.prompt()- this triggers the AI and returns immediately with message metadata - Stream events via
client.event.subscribe()- this gives you real-time updates as the AI generates its response
###Implementation
####1. Install the SDK
npm install @opencode-ai/sdk
####2. Create the API Route (app/api/chat/route.ts)
import { createOpencodeClient, type Event } from "@opencode-ai/sdk";
const client = createOpencodeClient({
baseUrl: "https://my-opencode-server.com",
});
export async function POST(request: Request) {
const { message, sessionId } = await request.json();
// Create a new session or use existing one
let session;
if (sessionId) {
session = await client.session.get({ path: { id: sessionId } });
} else {
session = await client.session.create({
body: { title: "Chat session" },
});
}
const currentSessionId = session.data?.id ?? sessionId;
// Create a readable stream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Subscribe to events BEFORE sending the prompt
const events = await client.event.subscribe();
// Send the prompt (this triggers the AI response)
const promptPromise = client.session.prompt({
path: { id: currentSessionId },
body: {
parts: [{ type: "text", text: message }],
},
});
// Process events
for await (const event of events.stream) {
// Filter events for our session
if (!("sessionID" in event.properties) ||
event.properties.sessionID !== currentSessionId) {
continue;
}
// Handle different event types
switch (event.type) {
case "message.part.updated": {
const part = event.properties.part;
// Stream text deltas
if (part.type === "text") {
const delta = event.properties.delta;
if (delta) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "delta", content: delta })}\n\n`)
);
}
}
// Stream tool calls
if (part.type === "tool" && part.state.status === "completed") {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({
type: "tool",
tool: part.tool,
input: part.state.input,
output: part.state.output
})}\n\n`)
);
}
break;
}
case "session.error": {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({
type: "error",
error: event.properties.error
})}\n\n`)
);
break;
}
case "session.idle": {
// Session finished processing
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "done", sessionId: currentSessionId })}\n\n`)
);
controller.close();
return;
}
}
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
####3. Client-Side Usage
async function chat(message: string, sessionId?: string) {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, sessionId }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader!.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n\n").filter(Boolean);
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
switch (data.type) {
case "delta":
// Append text to your UI
console.log("Text:", data.content);
break;
case "tool":
console.log("Tool used:", data.tool, data.input);
break;
case "done":
console.log("Session finished:", data.sessionId);
break;
case "error":
console.error("Error:", data.error);
break;
}
}
}
}
}
###Key Event Types
From packages/sdk/js/src/gen/types.gen.ts, the important events are:
| Event | Description |
|---|---|
message.part.updated | Contains text deltas and tool call updates |
message.updated | Full message info updates |
session.idle | Session has finished processing |
session.error | Error occurred during processing |
permission.asked | Tool needs permission (e.g., file write) |
###Alternative: Non-Streaming Approach
If you don't need streaming, session.prompt() returns directly when complete:
// This waits for the full AI response before returning
const result = await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{ type: "text", text: "Hello!" }],
},
});
// result.data contains { info: AssistantMessage, parts: Part[] }
###Sources
- SDK client:
packages/sdk/js/src/client.ts - Event types:
packages/sdk/js/src/gen/types.gen.ts - CLI streaming example:
packages/opencode/src/cli/cmd/run.ts(lines 154-207) - SDK documentation:
packages/web/src/content/docs/sdk.mdx
cool. so imagine the user sends a message and sees the stream. then they refresh: what do they see? what's loaded?
Now I have a complete picture. Let me provide you with a clear answer.
##What Happens on Refresh
When the user refreshes, they need to reload the session state from the server. Here's what's available:
###Data You Can Load
// 1. Get the session metadata
const session = await client.session.get({
path: { id: sessionId }
});
// Returns: Session { id, title, time, summary, share, revert, ... }
// 2. Get full message history with all parts
const messages = await client.session.messages({
path: { id: sessionId },
query: { limit: 100 } // optional limit
});
// Returns: Array<{ info: Message, parts: Part[] }>
###What's Persisted
Everything is persisted on the OpenCode server:
- Session metadata (title, timestamps, summary)
- All messages (user messages + assistant messages)
- All parts per message:
TextPart– the actual response textToolPart– tool calls with input/output/statusReasoningPart– thinking/reasoning contentFilePart– file attachmentsStepStartPart/StepFinishPart– step boundaries with token counts/costs
###Implementation for Your /api/chat Endpoint
Add a GET endpoint to load history:
// app/api/chat/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get("sessionId");
if (!sessionId) {
return Response.json({ error: "sessionId required" }, { status: 400 });
}
const [session, messages] = await Promise.all([
client.session.get({ path: { id: sessionId } }),
client.session.messages({ path: { id: sessionId } }),
]);
return Response.json({
session: session.data,
messages: messages.data, // Array of { info: Message, parts: Part[] }
});
}
###Client-Side: Loading on Mount
function ChatPage({ sessionId }: { sessionId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function loadHistory() {
const res = await fetch(`/api/chat?sessionId=${sessionId}`);
const { session, messages } = await res.json();
// messages is Array<{ info: Message, parts: Part[] }>
// You can render the full conversation from this
setMessages(messages);
setIsLoading(false);
}
loadHistory();
}, [sessionId]);
// Render messages...
}
###Message Structure
Each message in the history looks like:
{
info: {
id: "msg_abc123",
sessionID: "ses_xyz",
role: "assistant", // or "user"
time: { created: 1234567890, completed: 1234567900 },
cost: 0.0023,
tokens: { input: 1500, output: 200, reasoning: 0, cache: { read: 0, write: 500 } },
// ... more metadata
},
parts: [
{ type: "text", text: "Here's my response...", id: "part_1", ... },
{ type: "tool", tool: "read_file", state: { status: "completed", input: {...}, output: "..." }, ... },
{ type: "text", text: "Based on the file...", id: "part_2", ... },
]
}
###Key Points
- Session persists across server restarts – OpenCode stores everything
- Full fidelity – you get the exact same content, including tool calls, costs, tokens
- No streaming delta on reload – you get the completed text directly in
TextPart.text - Check session status – if the session was interrupted mid-response,
time.completedwill be undefined on the last assistant message
###Source Reference
The TUI does exactly this in packages/opencode/src/cli/cmd/tui/context/sync.tsx (lines 398-420):
async sync(sessionID: string) {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
// ... stores in local state
}
gotchu. and if there's a message that's currently streaming? i'd do this?
.messages()
.subscribe()
? like a "re-connect while a stream is ongoing" kind of scenario
This confirms exactly what you're thinking. The TUI does:
- Bootstrap: Sets up the event listener via
sdk.event.listen()immediately on mount - Load history: Calls
session.messages()to get existing messages - Merge events: The event handlers (
message.updated,message.part.updated) merge incoming updates into the existing state
##Yes, exactly right!
The pattern is:
// 1. Subscribe to events FIRST (so you don't miss anything)
const events = await client.event.subscribe();
// 2. Load existing history
const history = await client.session.messages({ path: { id: sessionId } });
// 3. Process events as they come in
for await (const event of events.stream) {
// merge into your state
}
###Why This Order Matters
If a stream is ongoing:
messages()gives you the current snapshot (partial text inTextPart.textif still streaming)event.subscribe()gives you the deltas (message.part.updatedwithdeltafield)
The events include the full part state each time, not just the delta, so even if you miss some events, the next message.part.updated will have the complete current state of that part.
From packages/sdk/js/src/gen/types.gen.ts:
export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part // Full part state (includes complete text so far)
delta?: string // Optional: just the new chunk (for streaming UX)
}
}
###Practical Implementation
// On reconnect / page load
async function reconnect(sessionId: string) {
// 1. Subscribe first
const events = await client.event.subscribe();
// 2. Load current state
const [session, messages] = await Promise.all([
client.session.get({ path: { id: sessionId } }),
client.session.messages({ path: { id: sessionId } }),
]);
// 3. Initialize UI with history
setState({ session: session.data, messages: messages.data });
// 4. Check if there's an in-progress message
const lastMessage = messages.data?.at(-1);
const isStreaming = lastMessage?.info.role === "assistant"
&& !lastMessage.info.time.completed;
// 5. Process live events
for await (const event of events.stream) {
if (event.properties.sessionID !== sessionId) continue;
switch (event.type) {
case "message.part.updated":
// Update/upsert the part in your state
// event.properties.part has the FULL current state
// event.properties.delta has just the new text (optional)
updatePart(event.properties.part);
break;
case "session.idle":
// Done streaming
break;
}
}
}
###Key Insight
You don't need to worry about "missing" events during the race between subscribe() and messages() because:
- Each
message.part.updatedevent contains the complete part state, not just incremental changes - The
deltafield is just a convenience for smooth streaming UI - you can always fall back to the fullpart.text
So worst case, you might re-render a part with data you already have - no data loss.
what about the storage part? if i want to have more metadata associated with, say, a session. is that recommended? would i need to eject to storing stuff in my database?
Now I have the full picture.
##Short Answer: You'll Need Your Own Database
The OpenCode Session schema is fixed and doesn't have a metadata field for custom data. Looking at the schema in packages/opencode/src/session/index.ts:
export const Info = z.object({
id: Identifier.schema("session"),
projectID: z.string(),
directory: z.string(),
parentID: z.string().optional(),
summary: z.object({ additions, deletions, files, diffs }).optional(),
share: z.object({ url: string }).optional(),
title: z.string(),
version: z.string(),
time: z.object({ created, updated, compacting, archived }),
permission: PermissionNext.Ruleset.optional(),
revert: z.object({ messageID, partID, snapshot, diff }).optional(),
})
The only user-writable fields via the API are:
title(viasession.createandsession.update)parentID(viasession.create)permission(viasession.create)
###Your Options
####1. Simple: Use a Mapping Table (recommended for most cases)
Just store a mapping of sessionId → your metadata in your own database:
// Your database schema (e.g., Drizzle, Prisma, etc.)
const chatSessions = pgTable('chat_sessions', {
id: text('id').primaryKey(), // matches opencode session ID
userId: text('user_id').notNull(),
workspaceId: text('workspace_id'),
tags: jsonb('tags'),
customField: text('custom_field'),
createdAt: timestamp('created_at').defaultNow(),
})
// On session create
const session = await client.session.create({ body: { title: "My chat" } })
await db.insert(chatSessions).values({
id: session.data.id,
userId: currentUser.id,
workspaceId: workspace.id,
// ... your custom fields
})
// On load
const [opencodeSession, metadata] = await Promise.all([
client.session.get({ path: { id: sessionId } }),
db.query.chatSessions.findFirst({ where: eq(chatSessions.id, sessionId) })
])
####2. Hacky: Abuse the Title Field
You could JSON-encode metadata into the title, but... don't:
// Please don't do this
await client.session.create({
body: { title: JSON.stringify({ displayTitle: "My Chat", userId: "123" }) }
})
####3. Full Control: Your Own Message Store
If you need deep customization (custom message types, reactions, threading beyond what opencode provides), you'd store messages yourself too:
// Your schema stores everything
const messages = pgTable('messages', {
id: text('id').primaryKey(),
sessionId: text('session_id').references(() => chatSessions.id),
opencodeMessageId: text('opencode_message_id'), // link to opencode
role: text('role'),
content: text('content'),
reactions: jsonb('reactions'),
// ... whatever you want
})
But this means you're now managing sync between your DB and opencode's state.
###Recommendation
Go with option 1 (mapping table). It's the cleanest:
- OpenCode handles the hard stuff (AI, streaming, tool calls, persistence)
- You handle your app-specific concerns (users, permissions, custom metadata)
- Session ID is your join key
- No data duplication or sync issues
END OF POST