Skip to content

Human In The Loop (HITL) โ€‹

HITL lets an agent pause before performing a high-risk action (send an email, charge a card, delete a record) and wait for a human decision. The approval request is persisted durably so the agent can resume even after a restart.

ts
import {
  InMemoryApprovalStore,
  SqliteApprovalStore,
  createSqliteApprovalStore,
  waitForApproval,
  ApprovalRejectedError,
} from 'confused-ai';

Quick start โ€‹

ts
import { createAgent, tool } from 'confused-ai';
import { createSqliteApprovalStore, waitForApproval, ApprovalRejectedError } from 'confused-ai';
import { z } from 'zod';

const approvalStore = createSqliteApprovalStore('./agent.db');

// Wrap a risky tool with an approval gate
const sendInvoice = tool({
  name: 'send_invoice',
  description: 'Send an invoice email to a customer.',
  schema: z.object({ customerId: z.string(), amount: z.number() }),
  execute: async ({ customerId, amount }, ctx) => {
    // Gate: pause here until a human approves
    await waitForApproval(approvalStore, {
      runId: ctx.runId!,
      agentName: 'billing-agent',
      toolName: 'send_invoice',
      toolArguments: { customerId, amount },
      riskLevel: 'high',
      description: `Send a $${amount} invoice to customer ${customerId}`,
      timeoutMs: 24 * 60 * 60 * 1000,  // 24 hours
    });

    // Only runs after approval
    await emailService.sendInvoice(customerId, amount);
    return { sent: true };
  },
});

const agent = createAgent({
  name: 'billing-agent',
  instructions: 'Handle billing and invoicing tasks.',
  model: 'gpt-4o-mini',
  apiKey: process.env.OPENAI_API_KEY!,
  tools: [sendInvoice],
});

try {
  const result = await agent.run('Send a $500 invoice to customer cust-123.');
} catch (err) {
  if (err instanceof ApprovalRejectedError) {
    console.log('Human rejected the action:', err.comment);
  }
}

Approval stores โ€‹

InMemoryApprovalStore (testing) โ€‹

ts
import { InMemoryApprovalStore } from 'confused-ai';

const store = new InMemoryApprovalStore();

SqliteApprovalStore (production) โ€‹

ts
import { createSqliteApprovalStore } from 'confused-ai';

const store = createSqliteApprovalStore('./agent.db');

ApprovalStore interface โ€‹

ts
interface ApprovalStore {
  create(request: Omit<HitlRequest, 'id' | 'status' | 'createdAt' | 'expiresAt'>): Promise<HitlRequest>;
  get(id: string): Promise<HitlRequest | null>;
  getByRunId(runId: string): Promise<HitlRequest[]>;
  decide(id: string, decision: ApprovalDecision): Promise<HitlRequest>;
  listPending(): Promise<HitlRequest[]>;
  cleanup(olderThanMs: number): Promise<number>;
}

HitlRequest shape โ€‹

ts
interface HitlRequest {
  readonly id: string;
  readonly runId: string;
  readonly agentName: string;
  readonly toolName: string;
  readonly toolArguments: Record<string, unknown>;
  readonly riskLevel: 'low' | 'medium' | 'high' | 'critical';
  readonly description?: string;
  readonly status: 'pending' | 'approved' | 'rejected' | 'expired';
  readonly comment?: string;          // reviewer comment
  readonly createdAt: string;
  readonly expiresAt: string;
  readonly decidedAt?: string;
}

HTTP approval endpoint โ€‹

When you serve your agent with createHttpService(), pass an approvalStore to expose a built-in REST endpoint:

ts
import { createHttpService } from 'confused-ai';
import { createSqliteApprovalStore } from 'confused-ai';

const approvalStore = createSqliteApprovalStore('./agent.db');

const app = createHttpService({ agent, approvalStore });
app.listen(3000);

List pending approvals:

GET /v1/approvals?status=pending

Submit a decision:

POST /v1/approvals/:id
Content-Type: application/json
{ "decision": "approved", "comment": "Looks good to me" }

Manual approval decision (testing) โ€‹

ts
// Simulate an approver submitting a decision
const pending = await approvalStore.listPending();
for (const req of pending) {
  await approvalStore.decide(req.id, {
    decision: 'approved',
    comment: 'Reviewed and approved.',
  });
}

Rejection handling โ€‹

When a human rejects a request, waitForApproval throws ApprovalRejectedError:

ts
import { ApprovalRejectedError } from 'confused-ai';

try {
  const result = await agent.run(prompt, { runId: 'run-abc' });
} catch (err) {
  if (err instanceof ApprovalRejectedError) {
    console.log('Rejected because:', err.comment);
    // Notify user, log, update UI, etc.
  }
}

Where to go next โ€‹

Released under the MIT License.