Web Inbox

The web inbox is always included in new Hitl(). No adapter package required. When a workflow calls waitForHuman, the server persists the request in state and records it for the inbox channel. Your app lists pending requests and resolves them via hitl.inbox.

Use the inbox for internal admin panels, custom REST APIs, or mobile apps. It is not mutually exclusive with the Chat SDK adapter: the same request can be visible in both if you configure delivery that way.

Deliver to the inbox from a workflow

By default, waitForHuman delivers to the inbox channel. Omit channel or pass "inbox" explicitly. The workflow only POSTs to your Hitl server; it does not render UI or call hitl.inbox directly.

In your workflow (after setting up a workflow client), request approval before sending email:

const emailDraft = { to: input.email, subject: input.subject, body: input.body };

const approval = await waitForHuman({
  message: `Send email to: ${input.email}?`,
  actions: actions().approve().deny().build(),
});

if (!isResolved(approval, "approve")) return;

await sendEmail(emailDraft);

Omit channel to use the inbox by default. Passing channel: "inbox" is equivalent.

List pending requests

On the server, hitl.inbox.list reads from state. Call it from route handlers, scripts, or background jobs that power your UI.

const { items, nextCursor } = await hitl.inbox.list({ status: "pending" });
// Omit `status` to include both pending and resolved (still one page)
const firstPage = await hitl.inbox.list();

list returns one page, newest-first: { items, nextCursor } — never the full set. Pass { status: "resolved" } to fetch completed requests, or omit status to return both statuses. To walk every request, page with nextCursor (see below).

Page through results with limit (default 50, max 10,000) and the previous page's nextCursor. When nextCursor is undefined you've reached the last page:

let cursor: string | undefined;
do {
  const page = await hitl.inbox.list({ status: "pending", limit: 50, cursor });
  for (const request of page.items) {
    // ...render or process each request
  }
  cursor = page.nextCursor;
} while (cursor);

Count requests

list returns one page, so use hitl.inbox.count for totals — a pending-count badge, a dashboard metric, or a monitoring alert. It runs COUNT(*) (SQL) or ZCARD (Redis), never loading the records:

const pending = await hitl.inbox.count({ status: "pending" });
// Omit `status` to count both pending and resolved
const total = await hitl.inbox.count();

Namespaces

A request belongs to a namespace — a logical partition of the inbox. Use it to keep separate teams, tenants, or workflows out of each other's lists. Set it when you create the request from a workflow; it defaults to "global":

const approval = await waitForHuman({
  namespace: "team-a",
  message: `Send email to: ${input.email}?`,
  actions: actions().approve().deny().build(),
});

Then scope list and count with the same namespace. Omit it to read across all namespaces (the default), so existing callers are unaffected:

const teamA = await hitl.inbox.list({ namespace: "team-a", status: "pending" });
const teamACount = await hitl.inbox.count({ namespace: "team-a" });

// No `namespace` → every namespace, exactly as before
const everything = await hitl.inbox.count();

namespace combines with status and pagination. It is indexed (a composite SQL index, a per-namespace Redis ZSET), so filtering stays efficient. Allowed characters are letters, digits, _, ., and - (max 128); other values are rejected with a 400.

Resolve a request

When a reviewer approves or denies, call hitl.inbox.resolve. Hitl updates state, notifies channel adapters, and calls your workflow engine's resolver to resume the suspended run.

await hitl.inbox.resolve(id, {
  actionId: "approve",
  by: { name: "you" },
  feedbacks: { subject: "Updated subject" }, // optional field edits
});

The id comes from hitl.inbox.list. The actionId must match an action you passed in actions() when creating the request.

Expose the inbox API

hitl.inbox is a programmatic API, not HTTP. Wrap it in your own routes so a browser or CLI can list and resolve requests. This step is required if you want humans to approve without building calls into server code directly.

Add handlers to your HTTP server (or extend server.ts from Host integration). Example using plain Node.js and JSON:

server.ts
import { createServer } from "node:http";
import { hitl } from "./lib/hitl";

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", "http://localhost");

  if (req.method === "GET" && url.pathname === "/api/inbox") {
    const status = url.searchParams.get("status");
    const limit = url.searchParams.get("limit");
    const page = await hitl.inbox.list({
      status: status === "pending" || status === "resolved" ? status : undefined,
      limit: limit ? Number(limit) : undefined,
      cursor: url.searchParams.get("cursor") ?? undefined,
    });
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(page)); // { items, nextCursor }
    return;
  }

  if (req.method === "POST" && url.pathname === "/api/inbox") {
    const body = JSON.parse(await readBody(req));
    const result = await hitl.inbox.resolve(body.id, {
      actionId: body.actionId,
      feedbacks: body.feedbacks,
      by: body.by,
    });
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ result }));
    return;
  }

  // ... mount /.well-known/hitl/v1 here too
}).listen(3000);

function readBody(req: import("node:http").IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on("data", (c) => chunks.push(c));
    req.on("end", () => resolve(Buffer.concat(chunks).toString()));
    req.on("error", reject);
  });
}

Build any UI on top of these endpoints: admin panels, internal tools, or mobile apps. The inbox channel stores the request; your handlers drive resolution.

Using Next.js, Express, Hono, or Fastify? See Host integration for mount patterns on each framework.

Batch requests

For multi-step approvals, use hitl.inbox.resolveBatch and batch listing APIs. See Human steps.