Skip to content

NestJS

Long Tail runs inside a NestJS application as one global module. At the end of this page your Nest app starts Long Tail before it serves traffic, shuts it down cleanly on exit, injects the SDK client into services and controllers, serves the dashboard at /admin/longtail, and signs users in to the dashboard with your app’s existing login.

  • A NestJS 11 or later app on the Express platform (@nestjs/platform-express). NestJS 11 runs on Express 5, the same major version as the router Long Tail mounts.

  • Postgres reachable through a connection string

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

    Terminal window
    npm install @hotmeshio/long-tail @hotmeshio/hotmesh

Everything this page imports from the package is a public export: start, createClient, LTExpressAdapter, and the types LTInstance, LTClient and SSOIdentity.

Long Tail Nest
start(config) resolves to { client, shutdown, adminUserId } after migrations, seeding and worker startup An async provider. Nest awaits it while it builds the module graph, so NestFactory.create() returns with Long Tail running.
shutdown() closes the server, adapters and workers onApplicationShutdown(), called on SIGTERM once enableShutdownHooks() is on
createClient({ auth }) A second provider that depends on the first
LTExpressAdapter Mounted on the underlying Express instance in main.ts

start() runs once per process. Its registries are process-wide, which is why the module is global.

  1. Declare injection tokens:

    src/long-tail/long-tail.tokens.ts
    export const LT_INSTANCE = Symbol('LT_INSTANCE');
    export const LT_CLIENT = Symbol('LT_CLIENT');
    export const LT_ADAPTER = Symbol('LT_ADAPTER');
  2. List your workers:

    src/long-tail/workers.ts
    import type { LTStartConfig } from '@hotmeshio/long-tail';
    import * as reviewContent from '../workflows/review-content';
    export const WORKERS: LTStartConfig['workers'] = [
    { taskQueue: 'long-tail', workflow: reviewContent.reviewContent },
    ];
  3. Write the module. The LT_INSTANCE factory calls start() with the built-in server off; Nest owns HTTP. seed.admin creates a superadmin service identity on first boot and returns its id on every boot, even when several replicas start at once:

    src/long-tail/long-tail.module.ts
    import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
    import {
    start,
    createClient,
    LTExpressAdapter,
    type LTInstance,
    type LTClient,
    } from '@hotmeshio/long-tail';
    import { WORKERS } from './workers';
    import { LT_ADAPTER, LT_CLIENT, LT_INSTANCE } from './long-tail.tokens';
    @Global()
    @Module({
    providers: [
    {
    provide: LT_INSTANCE,
    useFactory: async (): Promise<LTInstance> =>
    start({
    database: { connectionString: process.env.DATABASE_URL },
    server: { enabled: false },
    auth: { secret: process.env.JWT_SECRET },
    seed: { admin: { externalId: 'nest-service', displayName: 'Nest service' } },
    workers: WORKERS,
    }),
    },
    {
    provide: LT_CLIENT,
    inject: [LT_INSTANCE],
    useFactory: (lt: LTInstance): LTClient => {
    if (!lt.adminUserId) throw new Error('Long Tail did not return adminUserId; check seed.admin');
    return createClient({ auth: { userId: lt.adminUserId } });
    },
    },
    {
    provide: LT_ADAPTER,
    useFactory: (): LTExpressAdapter => {
    const adapter = new LTExpressAdapter();
    adapter.setBasePath('/admin/longtail');
    return adapter;
    },
    },
    ],
    exports: [LT_INSTANCE, LT_CLIENT, LT_ADAPTER],
    })
    export class LongTailModule implements OnApplicationShutdown {
    constructor(@Inject(LT_INSTANCE) private readonly lt: LTInstance) {}
    async onApplicationShutdown(): Promise<void> {
    await this.lt.shutdown();
    }
    }
  4. Import it once in the root module:

    src/app.module.ts
    import { Module } from '@nestjs/common';
    import { LongTailModule } from './long-tail/long-tail.module';
    import { ReviewModule } from './review/review.module';
    @Module({ imports: [LongTailModule, ReviewModule] })
    export class AppModule {}

Mount the router on the Express instance after NestFactory.create() (Long Tail is running by then) and before listen(). Attach the live-event transport to the HTTP server once it listens:

src/main.ts
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { LTExpressAdapter } from '@hotmeshio/long-tail';
import { AppModule } from './app.module';
import { LT_ADAPTER } from './long-tail/long-tail.tokens';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks();
const adapter = app.get<LTExpressAdapter>(LT_ADAPTER);
app.use('/admin/longtail', adapter.getRouter());
await app.listen(3000);
await adapter.attachServer(app.getHttpServer());
}
bootstrap();

Open http://localhost:3000/admin/longtail. Under that path the router serves the dashboard, /api/*, /mcp and /health; attachServer binds Socket.IO at /admin/longtail/socket.io, or the NATS WebSocket proxy when events.nats.wsProxy is set.

The router parses its own JSON bodies and leaves a body Nest already parsed as it is. Nest guards and interceptors do not run for it: requests to /admin/longtail/api authenticate through Long Tail’s own JWT or SSO.

LTClient carries every SDK namespace. Calls return { status, data?, error? } with the same status codes as the REST API.

src/review/review.service.ts
import { Inject, Injectable } from '@nestjs/common';
import type { LTClient } from '@hotmeshio/long-tail';
import { LT_CLIENT } from '../long-tail/long-tail.tokens';
@Injectable()
export class ReviewService {
constructor(@Inject(LT_CLIENT) private readonly lt: LTClient) {}
pending(role: string) {
return this.lt.escalations.list({ status: 'pending', role });
}
approve(id: string, userId: string) {
return this.lt.escalations.resolve(
{ id, resolverPayload: { approved: true } },
{ userId },
);
}
}

The second argument overrides the default identity for one call. Pass the acting user’s lt_users id when your app acts for a person.

src/review/workflow.controller.ts
import { Body, Controller, Get, HttpException, Inject, Param, Post } from '@nestjs/common';
import type { LTClient } from '@hotmeshio/long-tail';
import { LT_CLIENT } from '../long-tail/long-tail.tokens';
@Controller('workflows')
export class WorkflowController {
constructor(@Inject(LT_CLIENT) private readonly lt: LTClient) {}
@Post(':type/invoke')
async invoke(@Param('type') type: string, @Body() body: { data?: Record<string, unknown> }) {
const result = await this.lt.workflows.invoke({ type, data: body.data });
if (result.status >= 400) throw new HttpException(result.error ?? 'invoke failed', result.status);
return result.data; // { workflowId, message }
}
@Get(':id/status')
async status(@Param('id') id: string) {
const result = await this.lt.workflows.getStatus({ workflowId: id });
if (result.status >= 400) throw new HttpException(result.error ?? 'not found', result.status);
return result.data;
}
}
src/review/review.module.ts
import { Module } from '@nestjs/common';
import { ReviewService } from './review.service';
import { WorkflowController } from './workflow.controller';
@Module({ providers: [ReviewService], controllers: [WorkflowController] })
export class ReviewModule {}

Register callbacks in a provider’s onModuleInit and unsubscribe on destroy:

src/review/escalation-listener.ts
import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import type { LTClient } from '@hotmeshio/long-tail';
import { LT_CLIENT } from '../long-tail/long-tail.tokens';
@Injectable()
export class EscalationListener implements OnModuleInit, OnModuleDestroy {
private unsubscribe?: () => void;
constructor(@Inject(LT_CLIENT) private readonly lt: LTClient) {}
onModuleInit() {
this.unsubscribe = this.lt.events.on('escalation.*', (event) => {
console.log(event.type, event.escalationId, event.role);
});
}
onModuleDestroy() {
this.unsubscribe?.();
}
}

Your users authenticate with your Nest app. auth.sso.resolve reads that identity from the request; Long Tail provisions a matching user on first contact and issues its own JWT to the dashboard.

  1. Make the identity readable from the raw request. Nest guards do not run for the mounted router, so either register your session middleware with app.use() before mounting the router, or validate the session inside resolve.

  2. Pass your session service into the LT_INSTANCE factory and add auth.sso:

    src/long-tail/long-tail.module.ts
    {
    provide: LT_INSTANCE,
    inject: [SessionService], // your app's own service
    useFactory: async (sessions: SessionService): Promise<LTInstance> =>
    start({
    database: { connectionString: process.env.DATABASE_URL },
    server: { enabled: false },
    seed: { admin: { externalId: 'nest-service' } },
    workers: WORKERS,
    auth: {
    secret: process.env.JWT_SECRET,
    sso: {
    resolve: async (req): Promise<SSOIdentity | null> => {
    const session = await sessions.fromRequest(req);
    if (!session) return null;
    return {
    externalId: session.userId,
    displayName: session.name,
    email: session.email,
    roles: session.roles,
    };
    },
    roleMap: { admin: 'superadmin', reviewer: 'reviewer' },
    logoutUrl: '/auth/logout',
    keepaliveSeconds: 300,
    },
    },
    }),
    },

    Import SSOIdentity from @hotmeshio/long-tail alongside the other types, and the module that provides SessionService into LongTailModule.

  3. Open the dashboard while signed in to your app. It calls POST /admin/longtail/api/auth/sso with your cookies and receives a Long Tail token.

resolve(req, res) receives the response only on that exchange, so a host that mints its own session cookie can refresh it there (headers only). keepaliveSeconds re-runs the exchange while the dashboard is open and visible; values under 15 are raised to 15. With roleMap set, only mapped roles are granted. See Auth and SSO for the full contract.