Human-in-the-loop workflow orchestrator for TypeScript — chain steps, pause on approval gates, then continue when a human approves or rejects. Includes in-memory, SQLite (sql.js, file-backed), and PostgreSQL stores; optional Slack / HTTP webhook notifications; a small REST API; a CLI; and Vitest helpers.
| Feature | Description |
|---|---|
| Approval gates | Pause until approve or reject (optional comment) |
| Timeouts | e.g. timeout: '30m' with onTimeout: reject, escalate, or retry |
| Multi-approver | requiredApprovers: n — counts distinct approve decisions |
| Persistence | MemoryStore, SQLiteStore, PostgresStore |
| Notifications | Console default; Slack incoming webhook; custom HTTP webhook |
| Testing | TestOrchestrator + Vitest |
- Node.js 20+ (ESM; global
fetchin supported Node versions) - Optional: Docker (see
docker-compose.yml) for PostgreSQL
cd hitl-agent-ts
npm install
npm run buildnpm run build produces dist/, which is required for npm start, npm run start:api, and for imports that resolve to dist/ (including the hitl-agent-ts/hitl export in package.json).
Copy .env.example to .env when running the API with Slack or Postgres (see comments inside).
Use generics on HITLWorkflow<InputType, OutputType> so ctx.input is typed in steps:
import { HITLWorkflow, approval, MemoryStore } from './hitl/index.js';
type In = { userId: string };
type Out = { status: string };
const store = new MemoryStore();
const wf = new HITLWorkflow<In, Out>({
name: 'delete-user',
store,
})
.step('find-user', async (ctx) => ({
user: { id: ctx.input.userId, name: 'Ada' },
}))
.step(
'request-approval',
approval({
title: 'Delete user?',
description: (c) =>
`User ${(c.prevStep as { user: { name: string } }).user.name} will be deleted.`,
requiredApprovers: 1,
})
)
.step('execute', async (ctx) =>
ctx.approval?.decision === 'reject'
? { status: 'cancelled' }
: { status: 'deleted' }
);Orchestrator is exported as an alias for HITLWorkflow if you prefer that name.
import { startWorkflow, approveStep } from './hitl/index.js';
const run = await startWorkflow(wf, {
input: { userId: 'user-123' },
workflowId: 'delete-request-456',
});
console.log(run.status, run.currentStep); // paused at approval
await approveStep('delete-request-456', {
stepId: 'request-approval',
decision: 'approve',
approvedBy: 'admin-1',
});
const result = await run.waitForCompletion();
console.log(result);Same process: approveStep finds the active run in the same Node.js process that called startWorkflow. If the process restarts, load the same logical definition from code, then call await wf.resume(workflowId) to re-attach completion handling, then approve.
src/api/server.ts provides:
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Liveness |
| GET | /api/pending |
Pending approvals from the configured store |
| POST | /api/approve |
Body: { workflowId, stepId, decision, userId, comment? } |
With DATABASE_URL set, the server uses PostgresStore; otherwise MemoryStore.
npm run dev:api
# http://127.0.0.1:3000npm run cliExample: start user-123, then approve <workflowId> request-approval approve OK (the bundled CLI uses the request-approval step from examples/delete-user.ts).
npm run devRuns src/index.ts (sample delete-user workflow, pauses at approval).
| Script | Purpose |
|---|---|
npm run build |
Compile src/ → dist/ |
npm run dev |
Watch src/index.ts |
npm run dev:api |
Watch REST server |
npm run cli |
Interactive CLI |
npm test |
All Vitest tests |
npm run test:integration |
Integration tests |
npm run test:timeout |
Timeout behavior |
npm run test:sqlite |
SQLite store persistence |
npm run test:runtime |
getPendingApprovals / config helpers |
npm run lint |
tsc --noEmit |
prepublishOnly runs npm run build automatically before npm publish so dist/ is always fresh.
From the repo root: npm test and npm run lint before pushing. Scripts dev, dev:api, and cli run TypeScript via tsx and do not require dist/. You need npm run build for npm start / npm run start:api, for anything that imports from dist/, and before publishing. The default main entry is the demo (src/index.ts → dist/index.js); library code lives under src/hitl/ and is exposed as hitl-agent-ts/hitl after build.
docker compose up --buildBuilds the API image (Dockerfile) and starts PostgreSQL. The API process must still share the same workflow definitions and active runs as your worker if you use approveStep in-process; see “Same process” above.
hitl-agent-ts/
├── src/
│ ├── hitl/
│ │ ├── index.ts # public API barrel
│ │ ├── HITLWorkflow.ts # workflow builder + execution
│ │ ├── runtime.ts # startWorkflow, approveStep, getPendingApprovals
│ │ ├── approval.ts
│ │ ├── config.ts
│ │ ├── types.ts
│ │ ├── store/ # memory, sqlite, postgres
│ │ ├── notifiers/
│ │ └── testing.ts # TestOrchestrator
│ ├── examples/delete-user.ts
│ ├── api/server.ts
│ ├── cli/index.ts
│ └── index.ts
├── tests/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── LICENSE
└── README.md
| Export | Role |
|---|---|
HITLWorkflow / Orchestrator |
Build steps; approval({ ... }) for gates |
startWorkflow(wf, { input, workflowId? }) |
Returns WorkflowRun (waitForCompletion(), currentStep, status) |
wf.resume(workflowId) |
Re-attach after restart or to re-register waiters |
approveStep(workflowId, { stepId, decision, approvedBy, comment? }) |
Submit human decision |
getPendingApprovals(store?, filter?) |
With store: getPendingApprovals(store) or getPendingApprovals(store, { approvedBy }). With configureHitl(store): getPendingApprovals() or getPendingApprovals({ approvedBy }) or getPendingApprovals(undefined, { approvedBy }) |
configureHitl(store) |
Default store for getPendingApprovals() when no store is passed |
resetConfiguredStore() |
Clears the default store (tests / dev only) |
MemoryStore, SQLiteStore, PostgresStore |
Backends |
TestOrchestrator |
In-memory tests (orch.store must match the workflow’s store) |
MIT — see LICENSE.