Quickstart

This guide walks through a complete approval loop: a workflow suspends on waitForHuman, you approve via the web inbox, and the workflow resumes. It uses opinionated defaults:

  • Workflow engine: Workflow SDK
  • State: SQLite (swap to in-memory for the fastest local try)
  • Delivery: built-in web inbox (no Slack setup)
  • Host: Next.js App Router

To swap any axis later, see Install.

Prerequisites

  • Node.js 22.13+
  • A Next.js project with Workflow SDK configured (or clone the hello-world example)

1. Install packages

terminal
npm i @hitl-sdk/hitl @hitl-sdk/resolver-workflow-sdk @hitl-sdk/state-sqlite workflow

2. Create the server

lib/hitl.ts holds persistence, the resolver, and the built-in inbox channel:

lib/hitl.ts
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { Hitl } from "@hitl-sdk/hitl";
import { workflowResolver } from "@hitl-sdk/resolver-workflow-sdk";
import { SqliteState } from "@hitl-sdk/state-sqlite";

const dbPath = join(process.cwd(), ".hitl", "human_requests.db");
mkdirSync(join(process.cwd(), ".hitl"), { recursive: true });

export const hitl = new Hitl({
  state: new SqliteState(new DatabaseSync(dbPath)),
  resolver: workflowResolver(),
});

For a throwaway local demo, you can use InMemoryState from @hitl-sdk/hitl/state instead of SQLite. Pending requests reset on server restart.

3. Mount the internal API

Workflows POST to /.well-known/hitl/v1. In Next.js App Router:

app/.well-known/hitl/v1/[[...path]]/route.ts
import { hitl } from "@/lib/hitl";

export const { POST } = hitl.routeHandlers;

See Host integration (Next.js) for Express, Hono, and other frameworks.

4. Create the workflow client

The workflow runs in a separate sandbox and cannot import the server. Create a thin client with a durable "use step" fetch:

lib/hitl-client.ts
import type { HitlRequest } from "@hitl-sdk/hitl/core";
import { createWorkflowSdkHitlClient } from "@hitl-sdk/resolver-workflow-sdk";

async function hitlRequest(req: HitlRequest) {
  "use step";
  const res = await fetch(req.url, {
    method: req.method,
    headers: req.headers,
    body: req.body,
  });
  return { status: res.status, ok: res.ok, body: await res.text() };
}

export const { waitForHuman, requestHuman, notify } = createWorkflowSdkHitlClient({
  request: hitlRequest,
});

5. Write a workflow

workflows/hello.ts
import { actions, field, isResolved } from "@hitl-sdk/hitl";
import { waitForHuman } from "@/lib/hitl-client";

export async function helloWorkflow(name: string) {
  "use workflow";

  const approval = await waitForHuman({
    message: `Say hello to ${name}?`,
    actions: actions()
      .approve({ label: "Approve" })
      .deny({
        label: "Deny",
        fields: { reason: field.textArea({ label: "Reason" }) },
      })
      .build(),
  });

  if (!isResolved(approval, "approve")) {
    return { ok: false };
  }

  await greet(name);
  return { ok: true, greeted: name };
}

async function greet(name: string) {
  "use step";
  console.log(`Hello, ${name}!`);
}

6. Trigger the workflow

app/api/run/route.ts
import { helloWorkflow } from "@/workflows/hello";
import { start } from "workflow/api";

export async function POST(req: Request) {
  const body = (await req.json()) as { name?: string };
  const name = body.name ?? "world";
  const run = await start(helloWorkflow, [name]);
  return Response.json({ runId: run.runId, name });
}

7. Expose the inbox (optional custom UI)

The web inbox channel is built in. You can resolve approvals programmatically via hitl.inbox. The hello-world example wraps it in REST routes:

app/api/inbox/route.ts
import { hitl } from "@/lib/hitl";

export async function GET(req: Request) {
  const status = new URL(req.url).searchParams.get("status");
  const requests = await hitl.inbox.list(
    status === "pending" || status === "resolved" ? { status } : undefined,
  );
  return Response.json({ requests });
}

export async function POST(req: Request) {
  const { id, actionId, by, feedbacks } = await req.json();
  const result = await hitl.inbox.resolve(id, { actionId, feedbacks, by });
  return Response.json({ result });
}

See Web inbox for list/resolve options.

8. Run and verify

Start your dev server, then:

Start a workflow:

terminal
curl -s -X POST http://localhost:3000/api/run \
  -H 'content-type: application/json' \
  -d '{"name":"world"}'

List pending requests:

terminal
curl -s 'http://localhost:3000/api/inbox?status=pending'

Approve (replace <id> from the list):

terminal
curl -s -X POST http://localhost:3000/api/inbox \
  -H 'content-type: application/json' \
  -d '{"id":"<id>","actionId":"approve","by":{"name":"you"}}'

Check your dev server logs for Hello, world!.

What you built

PieceRole
lib/hitl.tsServer: state, resolver, inbox
.well-known/hitl/v1Internal API workflows POST to
lib/hitl-client.tsWorkflow-side client
workflows/hello.ts"use workflow" + waitForHuman
app/api/inboxYour UI/API on top of hitl.inbox

Swap an axis

Want to changeRead
Workflow SDK detailsWorkflow SDK
Temporal or InngestTemporal or Inngest
Postgres or RedisSQLite, Postgres, or Redis
Slack / TeamsChat SDK adapter
Express / Hono / …Host integration