How do I build a streaming /api/chat endpoint with opencode SDK?

dev
3a750b0Update aarch64-darwin hash
This post might have stale content, as dev is 1995 commits ahead.

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

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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:

  1. Send a prompt via client.session.prompt() - this triggers the AI and returns immediately with message metadata
  2. 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:

EventDescription
message.part.updatedContains text deltas and tool call updates
message.updatedFull message info updates
session.idleSession has finished processing
session.errorError occurred during processing
permission.askedTool 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

Avatar of julianbenegas
Julian Benegas
commented

cool. so imagine the user sends a message and sees the stream. then they refresh: what do they see? what's loaded?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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 text
    • ToolPart – tool calls with input/output/status
    • ReasoningPart – thinking/reasoning content
    • FilePart – file attachments
    • StepStartPart / 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

  1. Session persists across server restarts – OpenCode stores everything
  2. Full fidelity – you get the exact same content, including tool calls, costs, tokens
  3. No streaming delta on reload – you get the completed text directly in TextPart.text
  4. Check session status – if the session was interrupted mid-response, time.completed will 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
}
Avatar of julianbenegas
Julian Benegas
commented

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

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

This confirms exactly what you're thinking. The TUI does:

  1. Bootstrap: Sets up the event listener via sdk.event.listen() immediately on mount
  2. Load history: Calls session.messages() to get existing messages
  3. 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 in TextPart.text if still streaming)
  • event.subscribe() gives you the deltas (message.part.updated with delta field)

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:

  1. Each message.part.updated event contains the complete part state, not just incremental changes
  2. The delta field is just a convenience for smooth streaming UI - you can always fall back to the full part.text

So worst case, you might re-render a part with data you already have - no data loss.

Avatar of julianbenegas
Julian Benegas
commented

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?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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 (via session.create and session.update)
  • parentID (via session.create)
  • permission (via session.create)

###Your Options

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

How do I build a streaming /api/chat endpoint with opencode SDK? — anomalyco/opencode