- List Agents powers the left sidebar
- Get Agent Tasks powers the thread list for the selected agent
- Get Task Thread powers the message history view
- Invoke Agent (Sync) creates a new thread or appends to an existing one
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
- Loads all agents.
- Loads the selected agent’s task list as thread rows.
- Loads the selected thread’s full root conversation.
- Sends a new message without
idto create a brand-new thread. - Sends a message with
idto continue an existing thread.
Prerequisites
- Node.js 18 or newer
- An
XPANDER_API_KEY - Optionally
XPANDER_BASE_URLif you are not usinghttps://api.xpander.ai
Project Structure
Copy
Ask AI
xpander-threaded-inbox/
package.json
server.js
public/
index.html
1. package.json
package.json
Copy
Ask AI
{
"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/agentsGET /api/agents/:agentId/threadsGET /api/tasks/:taskId/messagesPOST /api/agents/:agentId/messages
server.js
Copy
Ask AI
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
Copy
Ask AI
<!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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
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
Copy
Ask AI
npm install
export XPANDER_API_KEY="your-api-key"
npm run dev
How to Use It
- Pick an agent in the left column.
- Click a thread in the middle column to load its history.
- Click
Start New Threadand send a message to create a new conversation. - 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
idcreates a new thread. - Sending a message with
idappends to the existing thread and keeps using that sametask.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
idto create a new thread - include
idto continue an existing thread - refresh
GET /v1/tasks/{task_id}/threadaftertask_finishedif you want the persisted message history
Production Notes
- Keep
XPANDER_API_KEYon the server, never in browser JavaScript - Paginate
/v1/agentsand/v1/agents/{agent_id}/tasksfor large workspaces - Use
/thread/fullinstead of/threadif you need sub-task visibility for multi-agent runs

