TypeScript SDK v4 is now available! See what's new

Human-in-the-loop (HITL)

Use step.waitForEvent() to pause agent execution for human approval, then resume or abort based on the response.

The basic pattern

Create a function that proposes an action, notifies a human, waits for a response, and resumes or aborts:

import { inngest } from "./client";

export const emailApprovalWorkflow = inngest.createFunction(
  { id: "email-approval-workflow", triggers: [{ event: "agent/email.draft-requested" }] },
  async ({ event, step }) => {
    const { recipient, context, userId } = event.data;

    // Step 1: Agent drafts the email
    const draft = await step.run("draft-email", async () => {
      return await generateEmail({
        recipient,
        context,
        tone: "professional",
      });
    });

    // Step 2: Notify the human via Slack
    await step.run("request-approval", async () => {
      await sendSlackMessage({
        channel: "#agent-approvals",
        blocks: [
          {
            type: "section",
            text: {
              type: "mrkdwn",
              text: `*Agent wants to send an email*\n\n*To:* ${recipient}\n*Subject:* ${draft.subject}\n\n${draft.body}`,
            },
          },
          {
            type: "actions",
            elements: [
              {
                type: "button",
                text: { type: "plain_text", text: "✅ Approve" },
                action_id: "approve_email",
                value: JSON.stringify({
                  approvalId: event.data.approvalId,
                  approved: true,
                }),
                style: "primary",
              },
              {
                type: "button",
                text: { type: "plain_text", text: "❌ Reject" },
                action_id: "reject_email",
                value: JSON.stringify({
                  approvalId: event.data.approvalId,
                  approved: false,
                }),
                style: "danger",
              },
            ],
          },
        ],
      });
    });

    // Step 3: Wait for human response — no compute cost while waiting
    const approval = await step.waitForEvent("wait-for-approval", {
      event: "agent/approval.response",
      match: "data.approvalId",
      timeout: "24h",
    });

    // Step 4: Handle the response
    // No event means it timed out
    if (!approval) {
      await step.run("notify-timeout", async () => {
        await sendSlackMessage({
          channel: "#agent-approvals",
          text: `⏰ Email approval timed out. Draft discarded.\n*To:* ${recipient}\n*Subject:* ${draft.subject}`,
        });
      });
      return { status: "timed_out", action: "email_not_sent" };
    }

    // The event payload can be used with whatever parameters that you send
    if (approval.data.approved) {
      await step.run("send-email", async () => {
        await sendEmail({
          to: recipient,
          subject: draft.subject,
          body: draft.body,
        });
      });
      return { status: "approved", action: "email_sent" };
    }

    return {
      status: "rejected",
      reason: approval.data.reason || "No reason provided",
      action: "email_not_sent",
    };
  }
);

The match field correlates the response to the correct waiting function — if you have 50 pending approvals, each resolves independently. The function is suspended while waiting, so there's no compute cost while a human reviews.

Send the approval response back

When the human clicks a button (Slack, email, dashboard, etc.), send an event to Inngest so step.waitForEvent() resolves.

From a Slack interaction webhook:

// NOTE - This is pseudo code for handling Slack interactions, please review their docs for implementation
app.post("/api/slack/interactions", async (req, res) => {
  const payload = JSON.parse(req.body.payload);
  const action = payload.actions[0];
  const value = JSON.parse(action.value);

  // Send the event using the client
  await inngest.send({
    name: "agent/approval.response",
    data: {
      approvalId: value.approvalId,
      approved: value.approved,
      respondedBy: payload.user.id,
      reason: value.approved ? undefined : "Rejected via Slack",
    },
  });

  res.json({ text: value.approved ? "✅ Approved" : "❌ Rejected" });
});

From a custom dashboard API:

app.post("/api/approvals/:approvalId/respond", async (req, res) => {
  const { approvalId } = req.params;
  const { approved, reason } = req.body;

  await inngest.send({
    name: "agent/approval.response",
    data: {
      approvalId,
      approved,
      respondedBy: req.user.id,
      reason,
    },
  });

  res.json({ status: "response_recorded" });
});

The event's data.approvalId must match the approvalId from the original request. This is how step.waitForEvent() correlates the response.

Handle approved, rejected, and timed-out responses

Every approval gate has three outcomes. Handle all three:

const approval = await step.waitForEvent("wait-for-approval", {
  event: "agent/approval.response",
  match: "data.approvalId",
  timeout: "24h",
});

if (!approval) {
  // TIMEOUT: No response within the window
  return { status: "timed_out" };
}

if (approval.data.approved) {
  // APPROVED: Proceed with the action
  const result = await step.run("execute-action", async () => {
    return await performAction(approval.data);
  });
  return { status: "approved", result };
}

// REJECTED
return { status: "rejected", reason: approval.data.reason };

Choose a timeout strategy

You can choose how your AI workflow handles the human-in-the-loop timeout. Here are some ideas for suggestions:

StrategyWhen to useImplementation
Auto-rejectHigh-risk actions (delete, deploy, send to external)Return early with status: "timed_out"
Auto-approveLow-risk, time-sensitive actionsProceed if !approval, same as approved path
EscalateActions that must get a responseNotify a different reviewer, then waitForEvent again
Retry notificationHuman might have missed the first messageRe-notify, then wait with a new timeout

To escalate when nobody responds, wait again with a new reviewer:

const approval = await step.waitForEvent("wait-for-approval", {
  event: "agent/approval.response",
  match: "data.approvalId",
  timeout: "4h",
});

if (!approval) {
  await step.run("escalate-to-manager", async () => {
    await sendSlackDM({
      userId: event.data.escalationContact,
      text: `⚠️ Approval needed — original reviewer didn't respond in 4 hours.\n\n${actionSummary}`,
    });
  });

  const escalatedApproval = await step.waitForEvent("wait-for-escalation", {
    event: "agent/approval.response",
    match: "data.approvalId",
    timeout: "4h",
  });

  if (!escalatedApproval) {
    return { status: "timed_out", escalated: true };
  }

  return handleApproval(escalatedApproval);
}

Add approval gates inside a tool loop

To gate dangerous tools while letting safe tools (reading data, searching) run freely, check tool names against an approval list inside the loop:

const APPROVAL_REQUIRED_TOOLS = ["send_email", "delete_record", "run_sql", "deploy"];

export const agentWithApproval = inngest.createFunction(
  { id: "agent-with-approval" },
  { event: "agent/task.received" },
  async ({ event, step }) => {
    let messages = [{ role: "user" as const, content: event.data.task }];
    let iterations = 0;

    while (iterations < 20) {
      iterations++;

      const llmResponse = await step.run(`think`, async () => {
        return await callLLM(messages, allTools);
      });

      if (!llmResponse.toolCalls.length) {
        return { response: llmResponse.text, iterations };
      }

      for (const toolCall of llmResponse.toolCalls) {
        if (APPROVAL_REQUIRED_TOOLS.includes(toolCall.name)) {
          // Create a unique approval ID that will not be re-used
          const approvalId = `${event.data.taskId}-${iterations}-${toolCall.name}`;

          await step.run(`request-approval-${approvalId}`, async () => {
            await sendSlackMessage({
              channel: "#agent-approvals",
              text: [
                `🔒 *Agent wants to execute: \`${toolCall.name}\`*`,
                `\`\`\`${JSON.stringify(toolCall.arguments, null, 2)}\`\`\``,
              ].join("\n"),
            });
          });

          const approval = await step.waitForEvent(
            `wait-approval-${approvalId}`,
            {
              event: "agent/approval.response",
              match: "data.approvalId",
              timeout: "4h",
            }
          );

          if (!approval?.data.approved) {
            messages.push({
              role: "tool" as const,
              content: `Tool call rejected by human reviewer. Reason: ${
                approval?.data.reason || "No response / timed out"
              }. Choose a different approach.`,
            });
            continue;
          }
        }

        const result = await step.run(
          `tool-${toolCall.name}`,
          async () => {
            return await executeTool(toolCall.name, toolCall.arguments);
          }
        );

        messages.push({ role: "tool" as const, content: result });
      }
    }

    return { status: "max_iterations_reached" };
  }
);

The loop pauses mid-iteration when a tool is called that requires approval. The human can take as long as the timeout waits - the function resumes exactly where it left off.

Chain multiple approval gates

To require sequential approvals from different reviewers (e.g., editorial then legal), chain multiple step.waitForEvent() calls:

export const multiApprovalWorkflow = inngest.createFunction(
  { id: "multi-approval-publish" },
  { event: "content/publish.requested" },
  async ({ event, step }) => {
    const { contentId } = event.data;

    const content = await step.run("generate-content", async () => {
      return await generateContent(contentId);
    });

    // --- Gate 1: Editorial approval ---
    await step.run("request-editorial-review", async () => {
      await sendSlackMessage({
        channel: "#editorial",
        text: `📝 Review needed: ${content.title}\n\n${content.preview}`,
      });
    });

    const editorialApproval = await step.waitForEvent("wait-editorial", {
      event: "content/review.completed",
      match: "data.contentId",
      timeout: "48h",
    });

    if (!editorialApproval?.data.approved) {
      return { status: "rejected_by_editorial" };
    }

    // --- Gate 2: Legal approval ---
    await step.run("request-legal-review", async () => {
      await sendSlackMessage({
        channel: "#legal-review",
        text: `⚖️ Legal review needed: ${content.title}\n\nEditorial approved. Awaiting legal sign-off.`,
      });
    });

    const legalApproval = await step.waitForEvent("wait-legal", {
      event: "content/legal-review.completed",
      match: "data.contentId",
      timeout: "72h",
    });

    if (!legalApproval?.data.approved) {
      return { status: "rejected_by_legal" };
    }

    // --- Both gates passed ---
    await step.run("publish", async () => {
      await publishContent(content);
    });

    return { status: "published", approvals: ["editorial", "legal"] };
  }
);

Each gate is durable and independent. If the editorial reviewer approves at 2 AM and the legal reviewer approves three days later, the function resumes correctly each time.

Next steps