Skip to content

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.

  • A Node project with @hotmeshio/long-tail and @hotmeshio/hotmesh installed
  • A Postgres database every container can reach
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 + workers
await start({ database: { connectionString: process.env.DATABASE_URL }, workers });
// Worker-only: no HTTP server
await start({ database: { connectionString: process.env.DATABASE_URL }, server: { enabled: false }, workers });
// Embedded: inside your app
await start({ database: { connectionString: process.env.DATABASE_URL }, server: { enabled: false } });
const lt = createClient({ auth: { userId } });
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().

  1. 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.

  2. 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());
  3. Build one image and override the command per service: node dist/api.js for the API, node dist/worker.js for workers.

The boilerplate keeps a single src/index.ts and branches on APP_ROLE:

src/index.ts
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 } }.

┌──────────────────────────────────┐
│ 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.

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.

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.