Skip to main content
Let’s build a chat UI that talks to your Chowder instance. We’ll use Next.js with TypeScript, but the patterns here work with any framework — the Chowder API is just REST. By the end, you’ll have a working chat interface with session support and model selection.
This guide shows the key pieces, not a full copy-paste app. For a complete working implementation, check the example/ folder in the Chowder repo.

Prerequisites

  • A running Chowder instance (create one first)
  • A scoped API key with read and interact permissions (create one)
  • Node.js 18+ and a Next.js project
Never use your organization key (chd_org_...) in frontend code. It has full admin access. Always use a scoped key (chd_sk_...) with the minimum permissions needed.
1
Set up environment variables
2
Create a .env.local in your Next.js project root:
3
NEXT_PUBLIC_API_URL=https://api.chowder.dev
NEXT_PUBLIC_API_KEY=chd_sk_your_scoped_key_here
4
The NEXT_PUBLIC_ prefix makes these available in client-side code. That’s fine for a scoped key with limited permissions — that’s exactly what scoped keys are for.
5
Create an API helper
6
Build a typed fetch wrapper that handles auth and errors. This keeps your components clean:
7
const BASE = process.env.NEXT_PUBLIC_API_URL || "https://api.chowder.dev";
const KEY = process.env.NEXT_PUBLIC_API_KEY || "";

function headers(): HeadersInit {
  return {
    "Content-Type": "application/json",
    Authorization: `Bearer ${KEY}`,
  };
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: headers(),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => res.statusText);
    throw new Error(text);
  }

  if (res.status === 204) return undefined as T;
  return res.json();
}
8
Now add typed methods for the endpoints you need:
9
// ---------- Types ----------

export interface Instance {
  id: string;
  name: string;
  status: string;
  model_provider: string;
  gateway_url: string | null;
  created_at: string;
}

export interface ResponseOutput {
  id: string;
  output: {
    type: string;
    role?: string;
    content?: { type: string; text: string }[];
  }[];
}

// ---------- API methods ----------

export function getInstance(id: string) {
  return request<Instance>(`/v1/instances/${id}`);
}

export function sendMessage(
  instanceId: string,
  input: string,
  model: string,
  sessionId?: string
) {
  const path = sessionId
    ? `/v1/instances/${instanceId}/session/${sessionId}/responses`
    : `/v1/instances/${instanceId}/responses`;

  return request<ResponseOutput>(path, {
    method: "POST",
    body: JSON.stringify({ model, input }),
  });
}
10
That’s the entire API layer. Two functions: get instance info and send messages. The scoped key handles auth automatically.
11
Build the chat component
12
Here’s a minimal chat component. It keeps messages in local state, sends them to the API, and renders the response:
13
"use client";

import { useState } from "react";
import { sendMessage, type ResponseOutput } from "@/lib/chowder";

interface Message {
  role: "user" | "assistant";
  text: string;
}

// Extract the assistant's text from the API response
function extractText(res: ResponseOutput): string {
  return (
    res.output
      ?.filter((o) => o.type === "message")
      .flatMap((o) => o.content ?? [])
      .filter((c) => c.type === "output_text")
      .map((c) => c.text)
      .join("\n") || "(no response)"
  );
}

export function Chat({ instanceId }: { instanceId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [model, setModel] = useState("claude-sonnet-4-20250514");
  const [loading, setLoading] = useState(false);
  const [sessionId, setSessionId] = useState<string | undefined>();

  const send = async () => {
    const text = input.trim();
    if (!text || loading) return;

    setMessages((prev) => [...prev, { role: "user", text }]);
    setInput("");
    setLoading(true);

    try {
      const res = await sendMessage(instanceId, text, model, sessionId);
      setMessages((prev) => [
        ...prev,
        { role: "assistant", text: extractText(res) },
      ]);
    } catch (e) {
      setMessages((prev) => [
        ...prev,
        {
          role: "assistant",
          text: `Error: ${e instanceof Error ? e.message : "Request failed"}`,
        },
      ]);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {/* Messages */}
      <div>
        {messages.map((msg, i) => (
          <div key={i} className={msg.role === "user" ? "text-right" : ""}>
            <span>{msg.role}</span>
            <p>{msg.text}</p>
          </div>
        ))}
        {loading && <p>Thinking...</p>}
      </div>

      {/* Input */}
      <form onSubmit={(e) => { e.preventDefault(); send(); }}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit" disabled={loading || !input.trim()}>
          Send
        </button>
      </form>
    </div>
  );
}
14
A few things to note about parsing the response:
15
  • output is an array — the agent might return multiple output items
  • Filter for type: "message" to get the assistant’s response
  • Each message has a content array of typed blocks — grab output_text for the text
  • 16
    Handle sessions
    17
    Sessions let you maintain separate conversation threads. Add session creation and switching:
    18
    const [sessions, setSessions] = useState<string[]>(["main"]);
    const [activeSession, setActiveSession] = useState("main");
    
    // Create a new session
    const createSession = (name: string) => {
      setSessions((prev) => [...prev, name]);
      setActiveSession(name);
    };
    
    // When sending, pass the session ID
    const res = await sendMessage(
      instanceId,
      text,
      model,
      activeSession === "main" ? undefined : activeSession
    );
    
    19
    The “main” session uses the default /responses endpoint (no session ID). Named sessions use /session/{name}/responses. They’re created automatically on first use — no setup required.
    20
    Render session tabs so users can switch:
    21
    <div className="flex gap-2">
      {sessions.map((s) => (
        <button
          key={s}
          onClick={() => setActiveSession(s)}
          className={s === activeSession ? "font-bold" : "opacity-60"}
        >
          {s}
        </button>
      ))}
      <button onClick={() => {
        const name = prompt("Session name:");
        if (name) createSession(name);
      }}>
        + New session
      </button>
    </div>
    
    22
    Add model selection
    23
    Let users pick which model the agent uses. This switches per-request — the agent’s memory persists across model changes:
    24
    const MODELS = [
      "claude-sonnet-4-20250514",
      "claude-haiku-4-20250414",
      "gpt-4o",
      "gpt-4o-mini",
      "gemini-2.0-flash",
    ];
    
    <select value={model} onChange={(e) => setModel(e.target.value)}>
      {MODELS.map((m) => (
        <option key={m} value={m}>{m}</option>
      ))}
    </select>
    
    25
    Available models depend on what your instance’s model provider supports. If you created the instance with model_provider: "anthropic", Claude models work out of the box. For GPT or Gemini models, make sure you’ve configured the corresponding API keys on your organization.
    26
    Error handling patterns
    27
    Here are the common errors you’ll hit and how to handle them gracefully:
    28
    async function safeSend(
      instanceId: string,
      input: string,
      model: string,
      sessionId?: string
    ): Promise<{ text: string; error?: boolean }> {
      try {
        const res = await sendMessage(instanceId, input, model, sessionId);
        return { text: extractText(res) };
      } catch (e) {
        const msg = e instanceof Error ? e.message : "Unknown error";
    
        // Parse the error for common cases
        if (msg.includes("401")) {
          return { text: "API key is invalid or expired.", error: true };
        }
        if (msg.includes("403")) {
          return {
            text: "Permission denied. Your key may not have 'interact' access.",
            error: true,
          };
        }
        if (msg.includes("409")) {
          return {
            text: "Instance is not running. It may need to be started.",
            error: true,
          };
        }
        if (msg.includes("502")) {
          return {
            text: "Gateway unavailable. The instance may be restarting.",
            error: true,
          };
        }
    
        return { text: `Something went wrong: ${msg}`, error: true };
      }
    }
    
    29
    StatusWhat it meansUser-facing message401Bad API key”Please check your API key”403Missing permission or wrong instance”You don’t have access to this”409Instance not running”The agent is offline. Try again later.”502Gateway down or sandbox issue”The agent is temporarily unavailable.”

    Putting it all together

    Here’s how you’d wire this into a Next.js page:
    app/chat/page.tsx
    import { Chat } from "@/components/Chat";
    
    export default function ChatPage() {
      // In a real app, you'd get this from your database or URL params
      const instanceId = "ead7b76b-f34e-4b91-93e7-9c979cf9e41c";
    
      return (
        <main className="max-w-2xl mx-auto p-8">
          <h1 className="text-2xl font-bold mb-6">Chat</h1>
          <Chat instanceId={instanceId} />
        </main>
      );
    }
    

    Full example

    The patterns above cover the essentials, but a production app needs more — loading states, scroll management, localStorage persistence, keyboard shortcuts, and proper styling. Check out the complete working example in the repo:
    example/
    ├── src/
    │   ├── lib/api.ts          # Full API client with types
    │   ├── components/
    │   │   ├── chat.tsx         # Chat with sessions, model picker, persistence
    │   │   ├── channels.tsx     # Channel management UI
    │   │   └── skills.tsx       # Skill install/uninstall UI
    │   └── ...
    
    The example uses Next.js 15, shadcn/ui components, and localStorage for session persistence. Clone it, drop in your env vars, and you’ve got a full dashboard.

    Architecture tips

    Call the Chowder API directly from the browser using a scoped key. This is what the example app does.Pros: Simple, no backend needed, instant setup.Cons: API key is visible in browser DevTools (that’s why you use a scoped key with minimal permissions).
    Browser → Chowder API → OpenClaw Instance
    

    What’s next?