Skip to content

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.

  • An Express application

  • Postgres reachable through a connection string

  • @hotmeshio/long-tail and @hotmeshio/hotmesh installed:

    Terminal window
    npm install @hotmeshio/long-tail @hotmeshio/hotmesh
  1. Start Long Tail with its own server turned off. seed.admin creates a superadmin service identity on first boot (idempotent) and returns its id as adminUserId. Without a password it 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 },
    ],
    });
    }
  2. 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();
  3. Open http://localhost:3000/admin/longtail.

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.

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 call
await 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.

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) => { /* ... */ });

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.

  1. 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 middleware
    app.use('/admin/longtail', adapter.getRouter());
  2. Add auth.sso to the start() 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,
    },
    },
  3. Load the dashboard. It sees SSO enabled in /api/settings, calls POST /admin/longtail/api/auth/sso with 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.

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.