Skip to main content
Multi-workflow HTTP server exposing workflows via REST. Supports run management, SSE event streaming, and human-in-the-loop approvals. For a lighter single-workflow server that runs alongside smithers up, see Serve Mode.

Import

import { startServer } from "smithers-orchestrator";

Quick Start

import { startServer } from "smithers-orchestrator";
import { drizzle } from "drizzle-orm/bun-sqlite";

const db = drizzle("./smithers.db");

const server = startServer({
  port: 7331,
  db,
  authToken: process.env.SMITHERS_API_KEY,
  rootDir: process.cwd(),
  allowNetwork: false,
});

ServerOptions

type ServerOptions = {
  port?: number;
  db?: BunSQLiteDatabase<any>;
  authToken?: string;
  maxBodyBytes?: number;
  rootDir?: string;
  allowNetwork?: boolean;
};
OptionTypeDefaultDescription
portnumber7331TCP port
dbBunSQLiteDatabaseundefinedSQLite database for mirroring run/event data; enables GET /v1/runs
authTokenstringprocess.env.SMITHERS_API_KEYBearer token. Falls back to env var. Disabled if neither is set.
maxBodyBytesnumber1048576 (1MB)Max request body size. Returns 413 if exceeded.
rootDirstringundefinedRoot for workflow path resolution and tool sandboxing
allowNetworkbooleanfalseAllow network access in bash
Returns an http.Server instance, already listening.

Effect API

startServerEffect returns an Effect wrapping the server startup for use inside Effect-based applications.
import { startServerEffect } from "smithers-orchestrator";
import { Effect } from "effect";

const program = startServerEffect({ port: 7331, db, rootDir: process.cwd() }).pipe(
  Effect.tap((server) => Effect.logInfo(`Server listening on port ${server.address()}`)),
);

Authentication

When authToken is configured, every request must include:
  • Authorization: Bearer <token>, or
  • x-smithers-key: <token>
Missing/invalid tokens receive 401.

Observability

The server participates in the standard observability pipeline:
  • smithers.http.requests counter
  • smithers.http.request_duration_ms histogram
  • Request handling, workflow loading, and body parsing wrapped in spans
  • Prometheus scrape endpoint at /metrics
OTLP export:
export SMITHERS_OTEL_ENABLED=1
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_SERVICE_NAME=smithers-server
Local collector stack:
docker compose -f observability/docker-compose.otel.yml up
See Observability for the full metrics list.

API Routes

All routes use JSON request/response bodies except GET /v1/runs/:runId/events (SSE) and GET /metrics (Prometheus text). JSON responses include Content-Type: application/json, Cache-Control: no-store, and X-Content-Type-Options: nosniff.

GET /metrics

Prometheus text exposition of runtime metrics.
# TYPE smithers_http_requests counter
smithers_http_requests 12
scrape_configs:
  - job_name: smithers
    static_configs:
      - targets: ["localhost:7331"]

POST /v1/runs

Start a new workflow run or resume an existing one.
{
  workflowPath: string;          // .tsx workflow file (required)
  input?: Record<string, any>;   // Workflow input (default: {})
  runId?: string;                // Custom run ID (default: auto-generated)
  resume?: boolean;              // Resume existing run (default: false)
  config?: {
    maxConcurrency?: number;
  };
}
Response: { "runId": "smi_abc123" } The workflow is dynamically imported, tables are auto-created, and the run starts asynchronously.
StatusCodeCondition
400INVALID_REQUESTMissing/invalid workflowPath, input, or config
400RUN_ID_REQUIREDresume: true without runId
400WORKFLOW_PATH_OUTSIDE_ROOTPath resolves outside rootDir
404RUN_NOT_FOUNDresume: true but run does not exist
409RUN_IN_PROGRESSRun with this ID already active
409RUN_ALREADY_EXISTSRun with this ID exists (no resume)

POST /v1/runs/:runId/resume

Resume a paused or failed run.
{
  workflowPath: string;
  input?: Record<string, any>;
  config?: { maxConcurrency?: number };
}
Response: { "runId": "smi_abc123" } If currently active, returns 200 with current status. Otherwise reloads the workflow and resumes from last checkpoint.
StatusCodeCondition
400INVALID_REQUESTMissing/invalid workflowPath
404RUN_NOT_FOUNDRun does not exist

POST /v1/runs/:runId/cancel

Cancel a running workflow. Signals the run’s AbortController. Response: { "runId": "smi_abc123" }
StatusCodeCondition
404NOT_FOUNDRun not in active runs

GET /v1/runs/:runId

Run status and summary.
{
  "runId": "smi_abc123",
  "workflowName": "bugfix",
  "status": "running",
  "startedAtMs": 1707500000000,
  "finishedAtMs": null,
  "summary": { "finished": 3, "in-progress": 1, "pending": 2 }
}
FieldTypeDescription
statusstringrunning, waiting-approval, finished, failed, cancelled
startedAtMsnumber | nullStart timestamp (ms)
finishedAtMsnumber | nullFinish timestamp (ms)
summaryobjectNode count by state

GET /v1/runs/:runId/events

SSE stream of lifecycle events.
ParameterTypeDefaultDescription
afterSeqnumber-1Only events after this sequence number
retry: 1000

event: smithers
data: {"type":"RunStarted","runId":"smi_abc123","timestampMs":1707500000000}

event: smithers
data: {"type":"NodeStarted","runId":"smi_abc123","nodeId":"analyze","iteration":0,"attempt":0,"timestampMs":1707500001000}

: keep-alive

event: smithers
data: {"type":"NodeFinished","runId":"smi_abc123","nodeId":"analyze","iteration":0,"attempt":0,"timestampMs":1707500010000}
  • Events named smithers with JSON payloads matching SmithersEvent.
  • Polls database every 500ms.
  • Keep-alive comment every 10s.
  • Closes on terminal state (finished, failed, cancelled).
  • Reconnect with afterSeq to resume.

GET /v1/runs/:runId/frames

List render frames.
ParameterTypeDefaultDescription
limitnumber50Max frames
afterFrameNonumberundefinedFrames after this number

POST /v1/runs/:runId/nodes/:nodeId/approve

Approve a node waiting for human approval.
{
  iteration?: number;     // Default: 0
  note?: string;
  decidedBy?: string;
}

POST /v1/runs/:runId/nodes/:nodeId/deny

Deny a node waiting for human approval.
{
  iteration?: number;     // Default: 0
  note?: string;
  decidedBy?: string;
}

GET /v1/runs

List all runs. Requires server-level db.
ParameterTypeDefaultDescription
limitnumber50Max runs
statusstringundefinedFilter by status
Returns 400 DB_NOT_CONFIGURED if no database was provided.

GET /v1/approvals

List all pending approvals across runs. Requires server-level db.
{
  "approvals": [
    {
      "runId": "smi_abc123",
      "nodeId": "deploy",
      "iteration": 0,
      "workflowName": "bugfix",
      "runStatus": "waiting-approval",
      "label": "deploy",
      "requestTitle": "deploy",
      "requestSummary": null,
      "requestedAtMs": 1707500100000,
      "waitingMs": 45000,
      "note": null,
      "decidedBy": null
    }
  ]
}
Results are sorted by requestedAtMs ascending (oldest first). Returns 400 DB_NOT_CONFIGURED if no database was provided. Also accessible at legacy paths: GET /v1/approval/list, GET /approval/list, GET /approvals.

POST /v1/runs/:runId/signals/:signalName

Deliver a named signal to a running workflow.
{
  data?: Record<string, any>;    // Signal payload (default: {})
  correlationId?: string;        // Optional correlation ID
  receivedBy?: string;           // Optional actor name
}
Response: { "delivered": true } (or false if the run has no listener for that signal name).
StatusCodeCondition
404NOT_FOUNDRun not found
Also accessible at legacy path: POST /signal/:runId/:signalName.

GET /health

Liveness probe. Returns 200 OK with a JSON body when the server is up.
curl http://localhost:7331/health
# {"ok":true}
No authentication is required for this endpoint — it is exempt from authToken checks.

Error Response Format

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable description",
    "details": {}
  }
}
Unhandled errors return 500 with code SERVER_ERROR.

Hot Reload

Each POST /v1/runs and POST /v1/runs/:runId/resume request performs a fresh load of the workflow file. The server hashes the source, writes a content-addressed shadow copy, and imports it via a unique URL. This means the running file on disk can be updated between requests without restarting the server — each new run picks up the latest version automatically. The shadow file is named .${workflowName}.smithers-${sha1hash}.tsx and lives next to the original. It is safe to delete these files after runs complete.

Run Heartbeat Tracking

Active runs write a heartbeat timestamp to _smithers_runs.heartbeat_at_ms every 5 seconds. The server uses this to distinguish truly running workflows from stale rows left by a previous process crash. isRunHeartbeatFresh returns false if the heartbeat is more than 5 seconds old. In that case:
  • POST /v1/runs with resume: true will resume rather than reject the run as already active.
  • POST /v1/runs/:runId/resume will resume rather than return 200 { status: "running" }.
  • POST /v1/runs/:runId/cancel returns 409 RUN_NOT_ACTIVE for stale runs instead of aborting.
On server shutdown, the close event fires the abort signal for all active run AbortControllers, giving workflows a chance to checkpoint before exit.

Database Mirroring

When a server-level db is provided and the workflow uses a different database, run metadata and events are mirrored asynchronously to the server database. This enables cross-workflow listing via GET /v1/runs.

Example

import { startServer } from "smithers-orchestrator";
import { drizzle } from "drizzle-orm/bun-sqlite";

const db = drizzle("./server.db");

const server = startServer({
  port: 7331,
  db,
  authToken: "sk-my-secret-token",
  rootDir: "/home/workflows",
  maxBodyBytes: 2 * 1024 * 1024,
  allowNetwork: false,
});
# Start a run
curl -X POST http://localhost:7331/v1/runs \
  -H "Authorization: Bearer sk-my-secret-token" \
  -H "Content-Type: application/json" \
  -d '{"workflowPath": "./bugfix.tsx", "input": {"description": "Fix the auth token expiry bug"}}'

# Stream events
curl -N http://localhost:7331/v1/runs/smi_abc123/events \
  -H "Authorization: Bearer sk-my-secret-token"

# Approve a node
curl -X POST http://localhost:7331/v1/runs/smi_abc123/nodes/deploy/approve \
  -H "Authorization: Bearer sk-my-secret-token" \
  -H "Content-Type: application/json" \
  -d '{"note": "Looks good", "decidedBy": "alice"}'

Next Steps