Mount in your framework

One Web-standard handler, mounted in Hono, Express, Fastify, NestJS, or any framework that speaks Request and Response.

@intentface/latch-server gives you the whole runtime as one handler:

ts
type LatchHandler = (request: Request) => Promise<Response | undefined>

undefined means "not one of my routes" — hand the request back to your host. That single signature is why the same code mounts everywhere.

latch-handler.ts
import { createLatchHandler } from "@intentface/latch-server";
import { runtime } from "./latch";

export const latch = createLatchHandler({
  runtime,
  basePath: "/api/latch",
  // Your existing auth, returning your Principal. Null → 401.
  resolveContext: async (request) => {
    const session = await auth.getSession(request);
    return session ? { orgId: session.orgId, userId: session.userId } : null;
  },
});

Web-standard hosts

Anything that already speaks Request/Response needs no adapter.

latch-server ships a Hono adapter on a subpath, so hono stays an optional peer:

ts
import { Hono } from "hono";
import { createLatchHono } from "@intentface/latch-server/hono";

const app = new Hono();

// Your middleware still applies — auth, CORS, logging.
app.route("/api/latch", createLatchHono({ runtime, resolveContext }));

export default app;

Or mount the bare handler yourself if you want to control the fallthrough:

ts
app.all("/api/latch/*", async (c) => (await latch(c.req.raw)) ?? c.notFound());

Node frameworks

Express, Fastify and NestJS predate Request/Response, so they go through toNodeHandler from @intentface/latch-server/node, which turns Node's req/res into the Web objects and back. Request bodies and answers stream through it; it resolves false for a path that isn't a Latch route, so your app can carry on.

latch-handler.ts
import { createLatchHandler } from "@intentface/latch-server";
import { toNodeHandler } from "@intentface/latch-server/node";

export const handle = toNodeHandler(createLatchHandler({ runtime, resolveContext, basePath: "/api/latch" }));
ts
import express from "express";
import { handle } from "./latch-handler";

const app = express();
// Before any body parser. Express 5 requires a NAMED wildcard — a bare
// "/api/latch/*" throws at registration. On Express 4 use "/api/latch/*".
app.all("/api/latch/*splat", async (req, res, next) => {
  if (!(await handle(req, res))) next();
});

app.use(express.json()); // the rest of your app
app.listen(3000);

Express 5 moved to path-to-regexp v8, which dropped anonymous *. Mounting with app.use("/api/latch", …) also works and is version-agnostic — but note req.url is then stripped of the mount prefix, so pass the same prefix as basePath or the handler won't recognise its own routes.

What the handler exposes

All paths are relative to basePath.

MethodPathDoes
GETagentsagents available to the caller, for a picker
POSTagents/:agent/:chatIdrun a turn — returns the stream
POSTagents/:agent/:chatId/approvalsresolve a human-in-the-loop gate
POSTagents/:agent/:chatId/tool-resultanswer a client-handled tool
GETchats · chats/:idthe caller's chats, and one chat's history
DELETEchats/:iddelete a chat and the subagent threads it spawned
GETrunsrun records, for an admin or debug view
GET POST DELETEschedules · schedules/:idmanage scheduled agents
GETconnectionsconnection status for the caller
GETconnections/:name/authorize · callbackthe OAuth dance
POSTconnections/:name/disconnect · testrevoke, or check liveness
GET POSTcron/runruntime.cron(), or your cronRun: fire all tenants' due schedules, reap + resume stalled runs

The turn route accepts { message }, { text }, or a useChat-style { messages } array and takes the last one — so an AI SDK client works without a translation layer.

Refusals a client should expect

StatuscodeMeaningWhat the client does
409pending_approvalA decision is outstanding on this chat.Render the approval; resolve it first.
409chat_busyA turn is already running in this chat (two tabs, a retry racing a reply). Nothing was persisted.Wait Retry-After (also retryAfterMs in the body) and resend the same message.
409chat_conflictThe client-chosen chat id exists and isn't yours.Pick another id.
429limit_exceededA turn, token or cost budget is spent.Wait Retry-After; tell the user which budget.

The cron route

cron/run is the only route that isn't user-authenticated: it fires every tenant's due schedules and finishes interrupted answers, so there's no principal to check. Guard it with a shared secret, sent as Authorization: Bearer <secret> or in the x-latch-cron-secret header, with GET or POST:

ts
createLatchHandler({
  runtime,
  resolveContext,
  cronSecret: process.env.LATCH_CRON_SECRET,
});

Omit cronSecret and the route doesn't exist at all — it fails closed.

Schedules also need a cron evaluator, since core ships no cron library:

ts
import { createCronerEvaluator } from "@intentface/latch-server";

createRuntime({ /* … */ cron: createCronerEvaluator() });

On a long-lived Node process you can skip the HTTP route entirely and use the in-process scheduler instead — see Turn on crash recovery.

Multi-tenancy

runtime accepts a resolver as well as a value, which is how you choose between the two isolation models:

ts
// One database, isolated by owner key on every row.
createLatchHandler({ runtime, resolveContext });

// A database per tenant — hard isolation.
createLatchHandler({
  runtime: (principal) => runtimeFor(principal.orgId),
  resolveContext,
  // No principal exists on cron/run, so supply the system-wide tick yourself.
  cronRun: async () => {
    for (const tenant of await listTenants()) {
      await (await runtimeFor(tenant)).cron();
    }
  },
});

Which one you want, and how the owner key follows from your Principal, is Principals.