Skip to main content
This example builds a small inbox-style app with the same task/thread model exposed by the v1 REST API:
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.

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

xpander-threaded-inbox/
  package.json
  server.js
  public/
    index.html

1. package.json

package.json
{
  "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
server.js
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
public/index.html
<!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

npm install
export XPANDER_API_KEY="your-api-key"
npm run dev
Open 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) because it is the smallest complete app. If you want live partial output, replace the send route with Invoke Agent (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