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).
What’s next?
Scoped API Keys
Learn more about creating and managing keys for your frontend.
Install Skills
Give your agent capabilities that your chat UI can leverage.