Express
Long Tail runs as a guest inside your Express app: no second server, no second port. At the end of this page your app serves the Long Tail dashboard, REST API and /mcp under /admin/longtail, your code calls the SDK in process, and your users reach the dashboard with the login they already have.
Prerequisites
Section titled “Prerequisites”-
An Express application
-
Postgres reachable through a connection string
-
@hotmeshio/long-tailand@hotmeshio/hotmeshinstalled:Terminal window npm install @hotmeshio/long-tail @hotmeshio/hotmesh
Embed and mount
Section titled “Embed and mount”-
Start Long Tail with its own server turned off.
seed.admincreates a superadmin service identity on first boot (idempotent) and returns its id asadminUserId. Without apasswordit is a service account that never logs in:src/long-tail.ts import { start } from '@hotmeshio/long-tail';import * as reviewContent from './workflows/review-content';export async function startLongTail() {return start({database: { connectionString: process.env.DATABASE_URL },server: { enabled: false },auth: { secret: process.env.JWT_SECRET },seed: { admin: { externalId: 'system', displayName: 'System' } },workers: [{ taskQueue: 'long-tail', workflow: reviewContent.reviewContent },],});} -
Mount the router at a subpath and attach the live-event transport to your HTTP server once it is listening:
src/server.ts import express from 'express';import { LTExpressAdapter, createClient } from '@hotmeshio/long-tail';import { startLongTail } from './long-tail';async function main() {const lt = await startLongTail();const app = express();const adapter = new LTExpressAdapter();adapter.setBasePath('/admin/longtail');app.use('/admin/longtail', adapter.getRouter());const server = app.listen(3000);await adapter.attachServer(server);const client = createClient({ auth: { userId: lt.adminUserId! } });// client.workflows.invoke(...), client.escalations.list(...)process.on('SIGTERM', () => lt.shutdown());}main(); -
Open
http://localhost:3000/admin/longtail.
What the router serves
Section titled “What the router serves”| Path under the base path | Serves |
|---|---|
/ |
The dashboard, with <base href> set to the base path |
/api/* |
The REST API, with its own JWT auth |
/mcp |
The MCP streamable-HTTP endpoint |
/health |
{ "status": "ok", "timestamp": ... } |
/socket.io |
Socket.IO, after attachServer |
attachServer binds Socket.IO to your server under <basePath>/socket.io, or the NATS WebSocket proxy when events.nats.wsProxy is set. It is safe to call when neither is registered.
Long Tail adds nothing global to your app. The router parses its own JSON bodies and leaves a body your app already parsed as it is. App-wide settings such as trust proxy stay yours.
Call the SDK in process
Section titled “Call the SDK in process”createClient returns the same operations as the REST API, with the same validation and RBAC, minus the HTTP round trip. Every call returns { status, data?, error? }:
const client = createClient({ auth: { userId: lt.adminUserId! } });
const started = await client.workflows.invoke({ type: 'reviewContent', data: { content: 'hello' } });if (started.status !== 202) throw new Error(started.error);// started.data.workflowId
const pending = await client.escalations.list({ status: 'pending', role: 'reviewer' });
// Override the default identity for one callawait client.escalations.claim({ id: escalationId, durationMinutes: 30 }, { userId: reviewerId });userId is an lt_users id. Use adminUserId for system calls and the acting user’s id when your app acts for a person.
Subscribe to events
Section titled “Subscribe to events”client.events.on registers an in-process callback. It accepts an exact type, a category wildcard or *, and returns an unsubscribe function:
const unsub = client.events.on('escalation.claimed', (event) => { console.log('claimed', event.escalationId, 'role', event.role);});
client.events.on('task.*', (event) => metrics.increment(event.type));client.events.on('*', (event) => auditLog.append(event));
unsub();Callbacks fire from the same publish calls that feed Socket.IO and NATS. To subscribe without the SDK client, register the adapter yourself:
import { eventRegistry, CallbackEventAdapter } from '@hotmeshio/long-tail';
const adapter = new CallbackEventAdapter();eventRegistry.register(adapter);await adapter.connect();adapter.on('task.completed', (event) => { /* ... */ });Share the host’s login (SSO)
Section titled “Share the host’s login (SSO)”When Long Tail is mounted in your app, your users already signed in to your app. auth.sso tells Long Tail how to read that identity from a request. Long Tail provisions a matching lt_users row on first contact and issues its own JWT for the dashboard.
-
Run your session middleware before the Long Tail router, so
req.user(or your equivalent) is set when Long Tail sees the request:app.use(hostSession); // your existing auth middlewareapp.use('/admin/longtail', adapter.getRouter()); -
Add
auth.ssoto thestart()config:auth: {secret: process.env.JWT_SECRET,sso: {resolve: (req) => {const user = (req as any).user;if (!user) return null;return {externalId: user.id,displayName: user.displayName,email: user.email,roles: user.roles,};},roleMap: { admin: 'superadmin', operator: 'reviewer' },logoutUrl: '/auth/logout',keepaliveSeconds: 300,},}, -
Load the dashboard. It sees SSO enabled in
/api/settings, callsPOST /admin/longtail/api/auth/ssowith your cookies, and stores the token it gets back.
| Option | Effect |
|---|---|
resolve(req, res?) |
Returns { externalId, displayName?, email?, roles?, metadata? } or null. res is present only on the exchange, for hosts that refresh their own session cookie (headers only). |
roleMap |
Maps host role names to Long Tail role names. A configured map is the complete contract: unmapped roles grant nothing. Omit it to pass role names through. |
defaultRoleType |
'member' (default) or 'admin' for provisioned users |
logoutUrl |
Where the dashboard sends the browser on logout |
keepaliveSeconds |
Re-runs the exchange on an interval while the dashboard is open and visible. Values under 15 are raised to 15. |
A request that reaches /api without a Bearer token falls back to sso.resolve(req), so your backend can forward cookies to the API. The /mcp endpoint takes only Bearer credentials.
SSO synchronizes identity, never authorization. A role the user already holds keeps the type and scope an admin set.
OAuth for MCP clients
Section titled “OAuth for MCP clients”To let MCP clients sign in through OAuth (auth.oauthServer or LT_OAUTH_ISSUER), mount the well-known router at your app’s root, and let the OAuth paths through your session gate:
app.use(adapter.getWellKnownRouter());| Paths under the base path | Gate |
|---|---|
/mcp, /api/oauth/register, /api/oauth/token, /api/oauth/revoke |
Outside your login; they carry their own credentials |
/api/oauth/authorize, the dashboard |
Behind your login; the person signs in before consenting |
Set the issuer to https://<host>/admin/longtail. The adapter logs a warning when the issuer path differs from the base path.
