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
readandinteractpermissions (create one) - Node.js 18+ and a Next.js project
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.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();
}
// ---------- 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 }),
});
}
That’s the entire API layer. Two functions: get instance info and send messages. The scoped key handles auth automatically.
Here’s a minimal chat component. It keeps messages in local state, sends them to the API, and renders the response:
"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>
);
}
output is an array — the agent might return multiple output itemstype: "message" to get the assistant’s responsecontent array of typed blocks — grab output_text for the textconst [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
);
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.<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>
Let users pick which model the agent uses. This switches per-request — the agent’s memory persists across model changes:
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>
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.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 };
}
}
Putting it all together
Here’s how you’d wire this into a Next.js page:app/chat/page.tsx
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:Architecture tips
- Client-side (simple)
- Server-side (production)
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).