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

# Threaded Agent Inbox App

> Build a simple web app that lists agents, shows threads per agent, loads thread history, and sends new or follow-up messages with Xpander's v1 REST API.

This example builds a small inbox-style app with the same task/thread model exposed by the v1 REST API:

* [List Agents](/api-reference/v1/agents/list-agents) powers the left sidebar
* [Get Agent Tasks](/api-reference/v1/agents/get-agent-tasks) powers the thread list for the selected agent
* [Get Task Thread](/api-reference/v1/tasks/get-thread) powers the message history view
* [Invoke Agent (Sync)](/api-reference/v1/agents/invoke-sync) creates a new thread or appends to an existing one

<Info>
  In this model, the returned task `id` is also the thread ID. Create a new thread by omitting `id`. Reply in an existing thread by sending the previous task `id` back as `id`.
</Info>

## What This App Does

1. Loads all agents.
2. Loads the selected agent's task list as thread rows.
3. Loads the selected thread's full root conversation.
4. Sends a new message without `id` to create a brand-new thread.
5. Sends a message with `id` to continue an existing thread.

## Prerequisites

* Node.js 18 or newer
* An `XPANDER_API_KEY`
* Optionally `XPANDER_BASE_URL` if you are not using `https://api.xpander.ai`

## Project Structure

```text theme={"dark"}
xpander-threaded-inbox/
  package.json
  server.js
  public/
    index.html
```

## 1. package.json

```json package.json theme={"dark"}
{
  "name": "xpander-threaded-inbox",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node server.js"
  },
  "dependencies": {
    "express": "^4.21.2"
  }
}
```

## 2. server.js

This tiny backend keeps your API key on the server and exposes four browser-safe routes:

* `GET /api/agents`
* `GET /api/agents/:agentId/threads`
* `GET /api/tasks/:taskId/messages`
* `POST /api/agents/:agentId/messages`

```js server.js theme={"dark"}
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";

const app = express();
const port = Number(process.env.PORT || 3000);
const baseUrl = process.env.XPANDER_BASE_URL || "https://api.xpander.ai";
const apiKey = process.env.XPANDER_API_KEY;

if (!apiKey) {
  throw new Error("Missing XPANDER_API_KEY");
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));

async function xpander(pathname, init = {}) {
  const response = await fetch(`${baseUrl}${pathname}`, {
    ...init,
    headers: {
      "x-api-key": apiKey,
      "Content-Type": "application/json",
      ...(init.headers || {})
    }
  });

  const text = await response.text();
  const data = text ? JSON.parse(text) : null;

  if (!response.ok) {
    const error = new Error(data?.detail || response.statusText);
    error.status = response.status;
    error.payload = data;
    throw error;
  }

  return data;
}

app.get("/api/agents", async (_req, res, next) => {
  try {
    const data = await xpander("/v1/agents?page=1&per_page=100", {
      method: "GET"
    });
    res.json(data);
  } catch (error) {
    next(error);
  }
});

app.get("/api/agents/:agentId/threads", async (req, res, next) => {
  try {
    const { agentId } = req.params;
    const data = await xpander(
      `/v1/agents/${agentId}/tasks?page=1&per_page=100`,
      { method: "GET" }
    );
    res.json(data);
  } catch (error) {
    next(error);
  }
});

app.get("/api/tasks/:taskId/messages", async (req, res, next) => {
  try {
    const { taskId } = req.params;
    const data = await xpander(`/v1/tasks/${taskId}/thread`, {
      method: "GET"
    });
    res.json(data);
  } catch (error) {
    next(error);
  }
});

app.post("/api/agents/:agentId/messages", async (req, res, next) => {
  try {
    const { agentId } = req.params;
    const { text, threadId } = req.body || {};

    if (!text || !text.trim()) {
      return res.status(400).json({ detail: "text is required" });
    }

    const body = {
      input: {
        text: text.trim()
      }
    };

    if (threadId) {
      body.id = threadId;
    }

    const task = await xpander(`/v1/agents/${agentId}/invoke`, {
      method: "POST",
      body: JSON.stringify(body)
    });

    res.json(task);
  } catch (error) {
    next(error);
  }
});

app.use((error, _req, res, _next) => {
  res.status(error.status || 500).json({
    detail: error.message || "Unexpected error",
    payload: error.payload || null
  });
});

app.listen(port, () => {
  console.log(`Inbox app running at http://localhost:${port}`);
});
```

## 3. public/index.html

The frontend is plain HTML, CSS, and browser JavaScript. It renders:

* an agent list
* a thread list for the selected agent
* a message history panel
* a composer that either creates a new thread or replies in the selected thread

```html public/index.html theme={"dark"}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Xpander Threaded Inbox</title>
    <style>
      :root {
        --bg: #0f172a;
        --panel: #111827;
        --panel-2: #1f2937;
        --border: #334155;
        --text: #e5e7eb;
        --muted: #94a3b8;
        --accent: #38bdf8;
        --accent-2: #0ea5e9;
        --user: #082f49;
        --assistant: #1e293b;
        --danger: #ef4444;
      }

      * {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        background: linear-gradient(180deg, #020617 0%, #0f172a 100%);
        color: var(--text);
      }

      .app {
        display: grid;
        grid-template-columns: 280px 320px 1fr;
        min-height: 100vh;
      }

      .panel {
        border-right: 1px solid var(--border);
        background: rgba(15, 23, 42, 0.85);
        backdrop-filter: blur(10px);
      }

      .panel:last-child {
        border-right: 0;
      }

      .panel-header {
        padding: 16px;
        border-bottom: 1px solid var(--border);
      }

      .panel-header h2,
      .panel-header h3 {
        margin: 0 0 6px;
        font-size: 16px;
      }

      .panel-header p {
        margin: 0;
        color: var(--muted);
        font-size: 13px;
      }

      .list {
        display: flex;
        flex-direction: column;
        gap: 8px;
        padding: 12px;
      }

      .list button,
      .toolbar button,
      .composer button {
        appearance: none;
        border: 1px solid var(--border);
        background: var(--panel);
        color: var(--text);
        border-radius: 12px;
        padding: 12px;
        text-align: left;
        cursor: pointer;
      }

      .list button.active,
      .toolbar button.active {
        border-color: var(--accent);
        background: rgba(14, 165, 233, 0.12);
      }

      .list button:hover,
      .toolbar button:hover,
      .composer button:hover {
        border-color: var(--accent-2);
      }

      .item-title {
        font-weight: 600;
        margin-bottom: 4px;
      }

      .item-meta {
        font-size: 12px;
        color: var(--muted);
      }

      .conversation {
        display: grid;
        grid-template-rows: auto auto 1fr auto;
        min-height: 100vh;
        background:
          radial-gradient(circle at top right, rgba(56, 189, 248, 0.14), transparent 28%),
          rgba(15, 23, 42, 0.95);
      }

      .toolbar {
        display: flex;
        gap: 8px;
        padding: 16px;
        border-bottom: 1px solid var(--border);
      }

      .status {
        padding: 0 16px 12px;
        color: var(--muted);
        font-size: 13px;
      }

      .messages {
        padding: 16px;
        overflow: auto;
        display: flex;
        flex-direction: column;
        gap: 12px;
      }

      .message {
        border: 1px solid var(--border);
        border-radius: 16px;
        padding: 14px;
        max-width: 720px;
        white-space: pre-wrap;
        line-height: 1.45;
      }

      .message.user {
        background: var(--user);
        align-self: flex-end;
      }

      .message.assistant {
        background: var(--assistant);
        align-self: flex-start;
      }

      .message .meta {
        font-size: 12px;
        color: var(--muted);
        margin-bottom: 8px;
      }

      .empty {
        color: var(--muted);
        padding: 20px 16px;
      }

      .composer {
        border-top: 1px solid var(--border);
        padding: 16px;
        display: grid;
        gap: 12px;
      }

      .composer textarea {
        width: 100%;
        min-height: 110px;
        border-radius: 14px;
        border: 1px solid var(--border);
        background: #020617;
        color: var(--text);
        padding: 12px;
        resize: vertical;
        font: inherit;
      }

      .composer button {
        width: fit-content;
        background: linear-gradient(135deg, var(--accent), var(--accent-2));
        color: #082f49;
        font-weight: 700;
        border: 0;
      }

      .error {
        color: var(--danger);
      }

      @media (max-width: 1100px) {
        .app {
          grid-template-columns: 1fr;
        }

        .panel {
          border-right: 0;
          border-bottom: 1px solid var(--border);
        }

        .conversation {
          min-height: auto;
        }
      }
    </style>
  </head>
  <body>
    <div class="app">
      <aside class="panel">
        <div class="panel-header">
          <h2>Agents</h2>
          <p>Select an agent to load its threads.</p>
        </div>
        <div id="agents" class="list"></div>
      </aside>

      <aside class="panel">
        <div class="panel-header">
          <h2>Threads</h2>
          <p>The selected agent's task list is the thread list.</p>
        </div>
        <div id="threads" class="list"></div>
      </aside>

      <main class="conversation">
        <div class="panel-header">
          <h3 id="conversationTitle">Conversation</h3>
          <p id="conversationSubtitle">Pick an agent to begin.</p>
        </div>

        <div class="toolbar">
          <button id="newThreadButton" type="button">Start New Thread</button>
        </div>

        <div id="status" class="status">Loading…</div>
        <div id="messages" class="messages"></div>

        <form id="composer" class="composer">
          <textarea
            id="prompt"
            placeholder="Type a message. If a thread is selected this will continue it. If not, it creates a new thread."
          ></textarea>
          <button type="submit">Send Message</button>
        </form>
      </main>
    </div>

    <script>
      const state = {
        agents: [],
        threads: [],
        messages: [],
        selectedAgentId: null,
        selectedThreadId: null,
        loading: false,
        error: null
      };

      const agentsEl = document.getElementById("agents");
      const threadsEl = document.getElementById("threads");
      const messagesEl = document.getElementById("messages");
      const statusEl = document.getElementById("status");
      const promptEl = document.getElementById("prompt");
      const composerEl = document.getElementById("composer");
      const newThreadButtonEl = document.getElementById("newThreadButton");
      const conversationTitleEl = document.getElementById("conversationTitle");
      const conversationSubtitleEl = document.getElementById("conversationSubtitle");

      async function api(url, init = {}) {
        const response = await fetch(url, {
          ...init,
          headers: {
            "Content-Type": "application/json",
            ...(init.headers || {})
          }
        });

        const data = await response.json();

        if (!response.ok) {
          throw new Error(data.detail || "Request failed");
        }

        return data;
      }

      function formatDate(value) {
        if (!value) return "Unknown time";
        const date =
          typeof value === "number"
            ? new Date(value * 1000)
            : new Date(value);
        return date.toLocaleString();
      }

      function selectedAgent() {
        return state.agents.find((agent) => agent.id === state.selectedAgentId) || null;
      }

      function selectedThread() {
        return state.threads.find((thread) => thread.id === state.selectedThreadId) || null;
      }

      function renderAgents() {
        if (!state.agents.length) {
          agentsEl.innerHTML = '<div class="empty">No agents found.</div>';
          return;
        }

        agentsEl.innerHTML = state.agents
          .map(
            (agent) => `
              <button
                type="button"
                class="${agent.id === state.selectedAgentId ? "active" : ""}"
                data-agent-id="${agent.id}"
              >
                <div class="item-title">${agent.name}</div>
                <div class="item-meta">${agent.status || "UNKNOWN"} · ${agent.model_name || "model n/a"}</div>
              </button>
            `
          )
          .join("");

        agentsEl.querySelectorAll("[data-agent-id]").forEach((button) => {
          button.addEventListener("click", async () => {
            const agentId = button.getAttribute("data-agent-id");
            await selectAgent(agentId);
          });
        });
      }

      function renderThreads() {
        if (!state.selectedAgentId) {
          threadsEl.innerHTML = '<div class="empty">Select an agent first.</div>';
          return;
        }

        if (!state.threads.length) {
          threadsEl.innerHTML = '<div class="empty">No threads yet. Start a new one.</div>';
          return;
        }

        threadsEl.innerHTML = state.threads
          .map(
            (thread) => `
              <button
                type="button"
                class="${thread.id === state.selectedThreadId ? "active" : ""}"
                data-thread-id="${thread.id}"
              >
                <div class="item-title">${thread.title || thread.id}</div>
                <div class="item-meta">
                  ${thread.status || "unknown"} · updated ${formatDate(thread.updated_at || thread.created_at)}
                </div>
              </button>
            `
          )
          .join("");

        threadsEl.querySelectorAll("[data-thread-id]").forEach((button) => {
          button.addEventListener("click", async () => {
            const threadId = button.getAttribute("data-thread-id");
            await openThread(threadId);
          });
        });
      }

      function renderMessages() {
        if (!state.selectedAgentId) {
          messagesEl.innerHTML = '<div class="empty">Select an agent to start browsing threads.</div>';
          return;
        }

        if (!state.messages.length) {
          messagesEl.innerHTML =
            '<div class="empty">No messages loaded. Start a new thread or choose one from the list.</div>';
          return;
        }

        messagesEl.innerHTML = state.messages
          .map(
            (message) => `
              <div class="message ${message.role}">
                <div class="meta">${message.role} · ${formatDate(message.created_at)}</div>
                <div>${escapeHtml(message.content || "")}</div>
              </div>
            `
          )
          .join("");

        messagesEl.scrollTop = messagesEl.scrollHeight;
      }

      function renderHeader() {
        const agent = selectedAgent();
        const thread = selectedThread();

        conversationTitleEl.textContent = agent
          ? `${agent.name}`
          : "Conversation";

        if (!agent) {
          conversationSubtitleEl.textContent = "Pick an agent to begin.";
          return;
        }

        if (!thread) {
          conversationSubtitleEl.textContent =
            "No thread selected. Sending a message now will create a brand-new thread.";
          return;
        }

        conversationSubtitleEl.textContent =
          `Thread ${thread.id} · latest title: ${thread.title || "(untitled)"}`;
      }

      function renderStatus() {
        if (state.error) {
          statusEl.innerHTML = `<span class="error">${escapeHtml(state.error)}</span>`;
          return;
        }

        if (state.loading) {
          statusEl.textContent = "Loading…";
          return;
        }

        if (!state.selectedAgentId) {
          statusEl.textContent = "Choose an agent.";
          return;
        }

        if (!state.selectedThreadId) {
          statusEl.textContent = "Ready to create a new thread.";
          return;
        }

        statusEl.textContent = `Replying in thread ${state.selectedThreadId}`;
      }

      function render() {
        renderAgents();
        renderThreads();
        renderMessages();
        renderHeader();
        renderStatus();
      }

      function escapeHtml(value) {
        return value
          .replaceAll("&", "&amp;")
          .replaceAll("<", "&lt;")
          .replaceAll(">", "&gt;");
      }

      async function selectAgent(agentId) {
        state.selectedAgentId = agentId;
        state.selectedThreadId = null;
        state.messages = [];
        state.error = null;
        state.loading = true;
        render();

        try {
          const data = await api(`/api/agents/${agentId}/threads`);
          state.threads = data.items || [];

          if (state.threads.length > 0) {
            await openThread(state.threads[0].id, false);
          } else {
            state.loading = false;
            render();
          }
        } catch (error) {
          state.error = error.message;
          state.loading = false;
          render();
        }
      }

      async function openThread(threadId, showLoading = true) {
        state.selectedThreadId = threadId;
        state.error = null;

        if (showLoading) {
          state.loading = true;
          render();
        }

        try {
          state.messages = await api(`/api/tasks/${threadId}/messages`);
        } catch (error) {
          state.error = error.message;
        } finally {
          state.loading = false;
          render();
        }
      }

      async function refreshThreads() {
        if (!state.selectedAgentId) return;
        const data = await api(`/api/agents/${state.selectedAgentId}/threads`);
        state.threads = data.items || [];
      }

      function startNewThread() {
        state.selectedThreadId = null;
        state.messages = [];
        state.error = null;
        render();
        promptEl.focus();
      }

      composerEl.addEventListener("submit", async (event) => {
        event.preventDefault();

        if (!state.selectedAgentId) {
          state.error = "Select an agent first.";
          render();
          return;
        }

        const text = promptEl.value.trim();
        if (!text) return;

        state.loading = true;
        state.error = null;
        render();

        try {
          const task = await api(`/api/agents/${state.selectedAgentId}/messages`, {
            method: "POST",
            body: JSON.stringify({
              text,
              threadId: state.selectedThreadId || undefined
            })
          });

          promptEl.value = "";
          await refreshThreads();
          await openThread(task.id, false);
        } catch (error) {
          state.error = error.message;
          state.loading = false;
          render();
        }
      });

      newThreadButtonEl.addEventListener("click", startNewThread);

      async function boot() {
        state.loading = true;
        render();

        try {
          const data = await api("/api/agents");
          state.agents = data.items || [];

          if (state.agents.length > 0) {
            await selectAgent(state.agents[0].id);
          } else {
            state.loading = false;
            render();
          }
        } catch (error) {
          state.error = error.message;
          state.loading = false;
          render();
        }
      }

      boot();
    </script>
  </body>
</html>
```

## 4. Run the App

```bash theme={"dark"}
npm install
export XPANDER_API_KEY="your-api-key"
npm run dev
```

Open [http://localhost:3000](http://localhost:3000).

## How to Use It

1. Pick an agent in the left column.
2. Click a thread in the middle column to load its history.
3. Click `Start New Thread` and send a message to create a new conversation.
4. Select an existing thread and send another message to continue it.

## Why This Matches the API

* The agent list comes from `GET /v1/agents`.
* The thread list comes from `GET /v1/agents/{agent_id}/tasks`.
* Each thread row is keyed by `task.id`.
* The message history for that thread comes from `GET /v1/tasks/{task_id}/thread`.
* Sending a new message without `id` creates a new thread.
* Sending a message with `id` appends to the existing thread and keeps using that same `task.id`.

## Streaming Upgrade

This example uses [Invoke Agent (Sync)](/api-reference/v1/agents/invoke-sync) because it is the smallest complete app. If you want live partial output, replace the send route with [Invoke Agent (Stream)](/api-reference/v1/agents/invoke-stream). The thread model stays the same:

* omit `id` to create a new thread
* include `id` to continue an existing thread
* refresh `GET /v1/tasks/{task_id}/thread` after `task_finished` if you want the persisted message history

## Production Notes

* Keep `XPANDER_API_KEY` on the server, never in browser JavaScript
* Paginate `/v1/agents` and `/v1/agents/{agent_id}/tasks` for large workspaces
* Use `/thread/full` instead of `/thread` if you need sub-task visibility for multi-agent runs
