Run modes
One call starts Long Tail: start(config). The config decides what the process runs: the HTTP server, the workers, or both. By the end of this page you can pick a mode per container and split one codebase into an API tier and a worker tier that scale on their own.
Prerequisites
Section titled “Prerequisites”- A Node project with
@hotmeshio/long-tailand@hotmeshio/hotmeshinstalled - A Postgres database every container can reach
The modes
Section titled “The modes”| Mode | server |
workers |
Runs |
|---|---|---|---|
| Standalone | enabled (default, port 3000) | your workflows | Dashboard, REST API, /mcp and workflow execution in one process |
| API | enabled | none, or readonly observers | Dashboard, REST API and /mcp; starts workflows, executes none |
| Worker | { enabled: false } |
your workflows | Workflow execution only; no inbound traffic |
| Embedded | { enabled: false } |
optional | Your app owns HTTP; mount the dashboard with LTExpressAdapter or call the SDK in process |
Every mode shares the same tables. Escalations created by a worker are claimable through any API container, and an invocation from any API container is picked up by any worker on that task queue.
// Standalone: dashboard + API + workersawait start({ database: { connectionString: process.env.DATABASE_URL }, workers });
// Worker-only: no HTTP serverawait start({ database: { connectionString: process.env.DATABASE_URL }, server: { enabled: false }, workers });
// Embedded: inside your appawait start({ database: { connectionString: process.env.DATABASE_URL }, server: { enabled: false } });const lt = createClient({ auth: { userId } });Why split API and workers
Section titled “Why split API and workers”| Concern | API container | Worker container |
|---|---|---|
| Purpose | Serve REST endpoints, authenticate requests, start workflows, query escalations | Execute workflow code and activities |
| Scaling trigger | Request volume | Workflow queue depth or CPU |
| Resource profile | I/O-bound (Postgres reads, HTTP) | Can be CPU-bound (AI calls, document processing) |
| Network exposure | Public, behind a load balancer | Private, outbound connections only |
| Deploy cadence | Change routes or auth without touching workers | Change workflow logic without restarting the API |
Both containers run the same image and the same connection string. They differ only in the config they pass to start().
Split one codebase into two entry points
Section titled “Split one codebase into two entry points”-
Write the API entry point. It serves HTTP and executes no workflows:
api.ts import { start } from '@hotmeshio/long-tail';await start({database: { connectionString: process.env.DATABASE_URL },server: { port: Number(process.env.PORT) || 3000 },auth: { secret: process.env.JWT_SECRET },events: process.env.NATS_URL? { nats: { url: process.env.NATS_URL, wsProxy: process.env.NATS_WS_TARGET, token: process.env.NATS_TOKEN } }: undefined,});Migrations run, the server starts, and routes and auth are mounted.
-
Write the worker entry point. It starts workers and no HTTP server:
worker.ts import { start } from '@hotmeshio/long-tail';import * as reviewContent from './workflows/review-content';const lt = await start({database: { connectionString: process.env.DATABASE_URL },server: { enabled: false },workers: [{ taskQueue: 'long-tail', workflow: reviewContent.reviewContent },],telemetry: process.env.HONEYCOMB_API_KEY? { honeycomb: { apiKey: process.env.HONEYCOMB_API_KEY } }: undefined,events: process.env.NATS_URL? { nats: { url: process.env.NATS_URL, token: process.env.NATS_TOKEN } }: undefined,logging: { pino: { level: 'info' } },});process.on('SIGTERM', () => lt.shutdown());process.on('SIGINT', () => lt.shutdown()); -
Build one image and override the command per service:
node dist/api.jsfor the API,node dist/worker.jsfor workers.
One entry point with APP_ROLE
Section titled “One entry point with APP_ROLE”The boilerplate keeps a single src/index.ts and branches on APP_ROLE:
const APP_ROLE = process.env.APP_ROLE as 'api' | 'worker' | undefined;const isWorker = APP_ROLE === 'worker';const isApi = APP_ROLE === 'api';
const lt = await start({ database: DB_CONFIG, server: isWorker ? { enabled: false } : { port: parseInt(process.env.PORT || '3030') }, workers: isApi ? READONLY_OBSERVERS : WORKERS, // ...auth, roles, agents, topics, mcp, events});APP_ROLE |
Mode |
|---|---|
| unset | Standalone (local development) |
api |
API container with readonly observers |
worker |
Worker container |
READONLY_OBSERVERS is the worker list with connection: { readonly: true } on each entry. A readonly worker registers the workflow so the dashboard discovers it and can invoke it, without competing with the real workers for work:
export const READONLY_OBSERVERS = WORKERS.map((w) => ({ ...w, connection: { readonly: true } as const,}));A string name with connection: { readonly: true } registers a workflow that runs in another codebase entirely: { taskQueue: 'ingest', workflow: 'orderPipeline', connection: { readonly: true } }.
Architecture
Section titled “Architecture” ┌──────────────────────────────────┐ │ Load balancer │ └───────────────┬──────────────────┘ │ ┌───────────────▼──────────────────┐ │ API containers (N) │ │ start({ server, database }) │ │ Dashboard, REST API, /mcp │ └───────────────┬──────────────────┘ │ ┌───────────────▼──────────────────┐ │ PostgreSQL │ │ Workflow state (HotMesh) │ │ Escalations, users, config │ └───────────────▲──────────────────┘ │ ┌───────────────┴──────────────────┐ │ Worker containers (M) │ │ start({ workers, server: off }) │ │ Workflows, activities, crons │ └──────────────────────────────────┘N and M scale independently. Workers need no inbound network access: they connect out to Postgres (and NATS, telemetry and model APIs as configured) and pull work from their task queues.
Environment by container
Section titled “Environment by container”| Variable | API | Worker | Description |
|---|---|---|---|
DATABASE_URL |
yes | yes | Postgres connection string, passed to database.connectionString |
PORT |
yes | HTTP listen port (default 3000) | |
JWT_SECRET |
yes | Signs and verifies JWTs | |
NATS_URL |
yes | yes | NATS server for cross-container events |
HONEYCOMB_API_KEY |
yes | Honeycomb telemetry (optional) | |
HMSH_TELEMETRY |
yes | Span verbosity: info or debug |
|
OPENAI_API_KEY, ANTHROPIC_API_KEY |
yes | For workflows that call a model (optional) |
start() reads its config from the object you pass. Environment variables reach it through your entry point, as process.env.DATABASE_URL does above.
Shutdown
Section titled “Shutdown”start() returns { client, shutdown, adminUserId }. shutdown() closes the HTTP server, disconnects MCP, crons, maintenance, events and telemetry, then stops the workers. Call it on SIGTERM so in-flight activities finish before the container exits. The boilerplate’s AWS worker service gives each task a 120-second stop timeout for this.
