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.
Prerequisites
Section titled “Prerequisites”-
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-tailand@hotmeshio/hotmeshinstalled: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.
How the lifecycle maps
Section titled “How the lifecycle maps”| 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.
Build the module
Section titled “Build the module”-
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'); -
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 },]; -
Write the module. The
LT_INSTANCEfactory callsstart()with the built-in server off; Nest owns HTTP.seed.admincreates 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();}} -
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 dashboard
Section titled “Mount the dashboard”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:
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.
Inject the client
Section titled “Inject the client”LTClient carries every SDK namespace. Calls return { status, data?, error? } with the same status codes as the REST API.
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.
Expose workflows through a controller
Section titled “Expose workflows through a controller”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; }}import { Module } from '@nestjs/common';import { ReviewService } from './review.service';import { WorkflowController } from './workflow.controller';
@Module({ providers: [ReviewService], controllers: [WorkflowController] })export class ReviewModule {}Subscribe to events
Section titled “Subscribe to events”Register callbacks in a provider’s onModuleInit and unsubscribe on destroy:
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?.(); }}Sign in with the host’s login (SSO)
Section titled “Sign in with the host’s login (SSO)”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.
-
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 insideresolve. -
Pass your session service into the
LT_INSTANCEfactory and addauth.sso:src/long-tail/long-tail.module.ts {provide: LT_INSTANCE,inject: [SessionService], // your app's own serviceuseFactory: 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
SSOIdentityfrom@hotmeshio/long-tailalongside the other types, and the module that providesSessionServiceintoLongTailModule. -
Open the dashboard while signed in to your app. It calls
POST /admin/longtail/api/auth/ssowith 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.
