In The Display Stack you learned how tools show UI and collect user input within a single conversation turn. But what happens when something can't be resolved right now?
The inbox is a persistent async mailbox. An agent posts a request it can't fulfill immediately — and an external service resolves it later. The next time the agent runs, it picks up the result automatically. This works across sessions, server restarts, and different instances of the same agent.
The display stack handles synchronous, in-process interactions well:
The inbox handles asynchronous, cross-instance scenarios:
Think of it this way: the display stack is like handing someone a clipboard and waiting. The inbox is like dropping a letter in a mailbox and checking back later.
glove_post_to_inbox with a tag, a natural language request, and a blocking flag"pending"agent.ask() runs, resolved items are injected into the conversation as text messages and marked "consumed"The inbox survives context compaction — pending items are preserved in the compaction summary so the agent never forgets what it's waiting for.
When your store implements inbox methods, Glove automatically registers the glove_post_to_inbox tool. The agent can call it whenever it decides something needs to be tracked asynchronously.
{
tag: string, // Category label, e.g. "restock_watch", "payment_pending"
request: string, // Natural language: "Notify when Yirgacheffe is back in stock"
blocking: boolean, // Default false. If true, agent should wait for resolution
}The tool returns the inbox item ID. The agent can reference it in conversation and the user sees it tracked in the UI.
| Mode | Behavior | Use for |
|---|---|---|
blocking: false | Agent continues normally, result arrives later | Restock watches, background jobs, optional notifications |
blocking: true | Agent is told it cannot proceed until resolved | Payment confirmations, required approvals, critical dependencies |
Blocking is soft enforcement — the agent receives a message saying it should wait, but it's not mechanically prevented from acting. This matches how tasks and permissions work in Glove.
The inbox is enabled by implementing four optional methods on your store. All built-in stores (SqliteStore, MemoryStore, createRemoteStore) already support it.
interface StoreAdapter {
// ...existing methods...
// Inbox (optional — enables glove_post_to_inbox when present)
getInboxItems?(): Promise<InboxItem[]>;
addInboxItem?(item: InboxItem): Promise<void>;
updateInboxItem?(
itemId: string,
updates: Partial<Pick<InboxItem, "status" | "response" | "resolved_at">>,
): Promise<void>;
getResolvedInboxItems?(): Promise<InboxItem[]>;
}When all four methods are present, Glove auto-registers the glove_post_to_inbox tool — just like glove_update_tasks is auto-registered when task methods exist.
type InboxItemStatus = "pending" | "resolved" | "consumed";
interface InboxItem {
id: string; // Auto-generated unique ID
tag: string; // Category label
request: string; // What the agent asked for (natural language)
response: string | null; // External service's response (null while pending)
status: InboxItemStatus; // Lifecycle state
blocking: boolean; // Whether the agent should wait
created_at: string; // ISO 8601 timestamp
resolved_at: string | null;
}Both request and response are plain text. The agent writes in natural language, and the external service responds in natural language. No structured payloads — the model interprets the text.
The whole point of the inbox is that something outside the agent resolves the request. Glove provides a static helper for this:
import { SqliteStore } from "glove-sqlite";
// Resolve an inbox item from any process that has DB access
const resolved = SqliteStore.resolveInboxItem(
"path/to/sessions.db", // Same DB the agent uses
"inbox_17119...", // The item ID
"Great news! The Yirgacheffe is back in stock and ready to order."
);
if (!resolved) {
console.log("Item not found or already resolved");
}This opens its own database connection, updates the item, and closes. It can be called from a completely separate process — a cron job, a webhook handler, an admin script, or another service entirely.
For web apps, you'll typically expose this as an API endpoint:
import { NextResponse } from "next/server";
import { SqliteStore } from "glove-sqlite";
export async function POST(req: Request) {
const { itemId, response } = await req.json();
const resolved = SqliteStore.resolveInboxItem(DB_PATH, itemId, response);
if (!resolved) {
return NextResponse.json({ error: "Not found or already resolved" }, { status: 404 });
}
return NextResponse.json({ ok: true });
}The useGlove hook returns inbox alongside tasks:
const { inbox, tasks, timeline, sendMessage } = useGlove({ tools, sessionId });
// Show pending watches in a sidebar
{inbox.filter(i => i.status === "pending").map(item => (
<div key={item.id}>
<span className="tag">{item.tag}</span>
<span className="request">{item.request}</span>
<span className="status">pending</span>
</div>
))}
// Resolved items show up too — until consumed by the agent
{inbox.filter(i => i.status === "resolved").map(item => (
<div key={item.id}>
<span className="tag">{item.tag}</span>
<span className="response">{item.response}</span>
<span className="status">resolved</span>
</div>
))}Inbox state is hydrated from the store on mount and refreshed after each processRequest call.
When using createRemoteStore (the typical setup for Next.js apps), you need to wire inbox actions to your API routes. Without these, inbox falls back to in-memory storage and items vanish on reload.
import type { RemoteStoreActions } from "glove-react";
export const storeActions: RemoteStoreActions = {
// ...existing getMessages, appendMessages...
// Inbox — required for persistence across reloads
getInboxItems: (sid) =>
fetch(`/api/sessions/${sid}/inbox`).then(r => r.json()),
addInboxItem: (sid, item) =>
fetch(`/api/sessions/${sid}/inbox`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item }),
}),
updateInboxItem: (sid, itemId, updates) =>
fetch(`/api/sessions/${sid}/inbox/update`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemId, updates }),
}),
getResolvedInboxItems: (sid) =>
fetch(`/api/sessions/${sid}/inbox/resolved`).then(r => r.json()),
};The corresponding API routes delegate to SqliteStore methods — the same pattern as the message routes.
Like all tools, the agent uses the inbox better when you explain it in the system prompt. Here's what works well:
## Async Notifications
- Some requests can't be fulfilled immediately (out of stock, pending approval, etc.)
- Use glove_post_to_inbox to track these for the customer
- Use a descriptive tag (e.g. "restock_watch", "approval_pending")
- Describe what the customer wants in natural language in the request field
- Set blocking=false unless the customer literally cannot proceed without the result
- When an inbox item is resolved, you'll see the response — inform the customerThe Coffee Shop example demonstrates the inbox with inventory tracking:
glove_post_to_inbox with tag "restock_watch"POST /api/inbox/simulate-restock)Agent calls glove_post_to_inbox
│
▼
┌─────────┐ External service ┌──────────┐ Next ask() call ┌──────────┐
│ pending │ ──────────────────▶ │ resolved │ ─────────────────▶ │ consumed │
└─────────┘ resolveInboxItem() └──────────┘ injected as text └──────────┘
│ │
│ (if blocking) │
▼ ▼
Agent sees transient Agent sees resolved
"still pending" reminder response in context
on each ask() call and informs the user| Feature | Display Stack | Inbox |
|---|---|---|
| Timing | Synchronous — within a single turn | Asynchronous — across sessions and instances |
| Resolved by | The user (clicking buttons, filling forms) | External services (background jobs, webhooks) |
| Persistence | Ephemeral — slots live in memory | Persistent — survives restarts and reloads |
| UI | Rich React components via render/renderResult | Text-based — agent interprets the response |
| Use for | Forms, confirmations, data cards | Restock watches, payment webhooks, background jobs |