> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chowder.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Frontend

> Build a chat interface with Next.js that connects to your Chowder instance.

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.

<Note>
  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](https://github.com/chowder-api/chowder/tree/main/example).
</Note>

## Prerequisites

* A running Chowder instance ([create one first](/guides/your-first-instance))
* A **scoped API key** with `read` and `interact` permissions ([create one](/guides/scoped-keys))
* Node.js 18+ and a Next.js project

<Warning>
  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.
</Warning>

<Steps>
  ### Set up environment variables

  Create a `.env.local` in your Next.js project root:

  ```bash .env.local theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  NEXT_PUBLIC_API_URL=https://api.chowder.dev
  NEXT_PUBLIC_API_KEY=chd_sk_your_scoped_key_here
  ```

  <Tip>
    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.
  </Tip>

  ### Create an API helper

  Build a typed fetch wrapper that handles auth and errors. This keeps your components clean:

  ```typescript lib/chowder.ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  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();
  }
  ```

  Now add typed methods for the endpoints you need:

  ```typescript lib/chowder.ts (continued) theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  // ---------- 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.

  ### Build the chat component

  Here's a minimal chat component. It keeps messages in local state, sends them to the API, and renders the response:

  ```tsx components/Chat.tsx theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  "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>
    );
  }
  ```

  A few things to note about parsing the response:

  * `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

  ### Handle sessions

  Sessions let you maintain separate conversation threads. Add session creation and switching:

  ```tsx Session management theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  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
  );
  ```

  <Tip>
    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.
  </Tip>

  Render session tabs so users can switch:

  ```tsx Session tabs theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  <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>
  ```

  ### Add model selection

  Let users pick which model the agent uses. This switches per-request — the agent's memory persists across model changes:

  ```tsx Model picker theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  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>
  ```

  <Note>
    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.
  </Note>

  ### Error handling patterns

  Here are the common errors you'll hit and how to handle them gracefully:

  ```typescript Error handling theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  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 };
    }
  }
  ```

  | Status | What it means                        | User-facing message                      |
  | ------ | ------------------------------------ | ---------------------------------------- |
  | `401`  | Bad API key                          | "Please check your API key"              |
  | `403`  | Missing permission or wrong instance | "You don't have access to this"          |
  | `409`  | Instance not running                 | "The agent is offline. Try again later." |
  | `502`  | Gateway down or sandbox issue        | "The agent is temporarily unavailable."  |
</Steps>

## Putting it all together

Here's how you'd wire this into a Next.js page:

```tsx app/chat/page.tsx theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
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

<Tabs>
  <Tab title="Client-side (simple)">
    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
    ```
  </Tab>

  <Tab title="Server-side (production)">
    Proxy requests through your own backend. Your server holds the API key, the browser never sees it.

    **Pros**: Key is secret, you can add your own auth/rate limiting/logging.

    **Cons**: More infrastructure to maintain.

    ```
    Browser → Your API → Chowder API → OpenClaw Instance
    ```

    With Next.js Route Handlers:

    ```typescript app/api/chat/route.ts theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
    import { NextRequest, NextResponse } from "next/server";

    const CHOWDER_URL = process.env.CHOWDER_API_URL!;
    const CHOWDER_KEY = process.env.CHOWDER_API_KEY!; // Server-only, no NEXT_PUBLIC_

    export async function POST(req: NextRequest) {
      const { instanceId, input, model, sessionId } = await req.json();

      // Add your own auth check here
      // const user = await getUser(req);

      const path = sessionId
        ? `/v1/instances/${instanceId}/session/${sessionId}/responses`
        : `/v1/instances/${instanceId}/responses`;

      const res = await fetch(`${CHOWDER_URL}${path}`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${CHOWDER_KEY}`,
        },
        body: JSON.stringify({ model, input }),
      });

      const data = await res.json();
      return NextResponse.json(data, { status: res.status });
    }
    ```
  </Tab>
</Tabs>

## What's next?

<CardGroup cols={2}>
  <Card title="Scoped API Keys" icon="key" href="/guides/scoped-keys">
    Learn more about creating and managing keys for your frontend.
  </Card>

  <Card title="Install Skills" icon="puzzle-piece" href="/guides/install-skills">
    Give your agent capabilities that your chat UI can leverage.
  </Card>
</CardGroup>
