# Human-in-the-loop (HITL)

Use [`step.waitForEvent()`](/docs-markdown/durable-execution/primitives/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:

```typescript {{ title: "TypeScript" }}
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",
    };
  }
);
```

```python {{ title: "Python" }}
import datetime
import json
import typing

import inngest

inngest_client = inngest.Inngest(app_id="my-app")

@inngest_client.create_function(
    fn_id="email-approval-workflow",
    trigger=inngest.TriggerEvent(event="agent/email.draft-requested"),
)
async def email_approval_workflow(ctx: inngest.Context) -> dict[str, str]:
    recipient = str(ctx.event.data["recipient"])
    context = str(ctx.event.data["context"])
    approval_id = ctx.event.data["approvalId"]

    # Step 1: Agent drafts the email
    async def draft_email() -> dict[str, str]:
        return await generate_email(
            recipient=recipient, context=context, tone="professional"
        )

    draft = await ctx.step.run("draft-email", draft_email)

    # Step 2: Notify the human via Slack
    async def request_approval() -> None:
        await send_slack_message(
            channel="#agent-approvals",
            blocks=[
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": (
                            "*Agent wants to send an email*\n\n"
                            f"*To:* {recipient}\n"
                            f"*Subject:* {draft['subject']}\n\n"
                            f"{draft['body']}"
                        ),
                    },
                },
                {
                    "type": "actions",
                    "elements": [
                        {
                            "type": "button",
                            "text": {
                                "type": "plain_text",
                                "text": "✅ Approve",
                            },
                            "action_id": "approve_email",
                            "value": json.dumps(
                                {"approvalId": approval_id, "approved": True}
                            ),
                            "style": "primary",
                        },
                        {
                            "type": "button",
                            "text": {
                                "type": "plain_text",
                                "text": "❌ Reject",
                            },
                            "action_id": "reject_email",
                            "value": json.dumps(
                                {"approvalId": approval_id, "approved": False}
                            ),
                            "style": "danger",
                        },
                    ],
                },
            ],
        )

    await ctx.step.run("request-approval", request_approval)

    # Step 3: Wait for human response — no compute cost while waiting
    approval = await ctx.step.wait_for_event(
        "wait-for-approval",
        event="agent/approval.response",
        if_exp="async.data.approvalId == event.data.approvalId",
        timeout=datetime.timedelta(hours=24),
    )

    # Step 4: Handle the response
    # No event means it timed out
    if approval is None:

        async def notify_timeout() -> None:
            await send_slack_message(
                channel="#agent-approvals",
                text=(
                    "⏰ Email approval timed out. Draft discarded.\n"
                    f"*To:* {recipient}\n*Subject:* {draft['subject']}"
                ),
            )

        await ctx.step.run("notify-timeout", notify_timeout)
        return {"status": "timed_out", "action": "email_not_sent"}

    # The event payload can be used with whatever parameters that you send
    if approval.data.get("approved"):

        async def send() -> None:
            await send_email(
                to=recipient, subject=draft["subject"], body=draft["body"]
            )

        await ctx.step.run("send-email", send)
        return {"status": "approved", "action": "email_sent"}

    return {
        "status": "rejected",
        "reason": str(approval.data.get("reason") or "No reason provided"),
        "action": "email_not_sent",
    }
```

```go {{ title: "Go" }}
import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	"github.com/inngest/inngestgo"
	"github.com/inngest/inngestgo/step"
)

type EmailDraftRequested struct {
	Recipient  string `json:"recipient"`
	Context    string `json:"context"`
	UserID     string `json:"userId"`
	ApprovalID string `json:"approvalId"`
}

type ApprovalResponseEvent = inngestgo.GenericEvent[ApprovalResponse]

func EmailApprovalWorkflow(client inngestgo.Client) (inngestgo.ServableFunction, error) {
	return inngestgo.CreateFunction(
		client,
		inngestgo.FunctionOpts{ID: "email-approval-workflow"},
		inngestgo.EventTrigger("agent/email.draft-requested", nil),
		func(ctx context.Context, input inngestgo.Input[EmailDraftRequested]) (any, error) {
			data := input.Event.Data

			// Step 1: Agent drafts the email
			draft, err := step.Run(ctx, "draft-email", func(ctx context.Context) (Draft, error) {
				return generateEmail(ctx, GenerateEmailInput{
					Recipient: data.Recipient,
					Context:   data.Context,
					Tone:      "professional",
				})
			})
			if err != nil {
				return nil, err
			}

			// Step 2: Notify the human via Slack
			_, err = step.Run(ctx, "request-approval", func(ctx context.Context) (any, error) {
				approve, _ := json.Marshal(map[string]any{"approvalId": data.ApprovalID, "approved": true})
				reject, _ := json.Marshal(map[string]any{"approvalId": data.ApprovalID, "approved": false})
				return nil, sendSlackMessage(ctx, SlackMessage{
					Channel: "#agent-approvals",
					Blocks: []map[string]any{
						{
							"type": "section",
							"text": map[string]any{
								"type": "mrkdwn",
								"text": fmt.Sprintf("*Agent wants to send an email*\n\n*To:* %s\n*Subject:* %s\n\n%s", data.Recipient, draft.Subject, draft.Body),
							},
						},
						{
							"type": "actions",
							"elements": []map[string]any{
								{
									"type":      "button",
									"text":      map[string]any{"type": "plain_text", "text": "✅ Approve"},
									"action_id": "approve_email",
									"value":     string(approve),
									"style":     "primary",
								},
								{
									"type":      "button",
									"text":      map[string]any{"type": "plain_text", "text": "❌ Reject"},
									"action_id": "reject_email",
									"value":     string(reject),
									"style":     "danger",
								},
							},
						},
					},
				})
			})
			if err != nil {
				return nil, err
			}

			// Step 3: Wait for human response — no compute cost while waiting
			approval, err := step.WaitForEvent[ApprovalResponseEvent](ctx, "wait-for-approval", step.WaitForEventOpts{
				Event:   "agent/approval.response",
				If:      inngestgo.StrPtr("async.data.approvalId == event.data.approvalId"),
				Timeout: 24 * time.Hour,
			})

			// Step 4: Handle the response
			// ErrEventNotReceived means it timed out
			if errors.Is(err, step.ErrEventNotReceived) {
				_, err := step.Run(ctx, "notify-timeout", func(ctx context.Context) (any, error) {
					return nil, sendSlackMessage(ctx, SlackMessage{
						Channel: "#agent-approvals",
						Text:    fmt.Sprintf("⏰ Email approval timed out. Draft discarded.\n*To:* %s\n*Subject:* %s", data.Recipient, draft.Subject),
					})
				})
				if err != nil {
					return nil, err
				}
				return map[string]any{"status": "timed_out", "action": "email_not_sent"}, nil
			}
			if err != nil {
				return nil, err
			}

			// The event payload can be used with whatever parameters that you send
			if approval.Data.Approved {
				_, err := step.Run(ctx, "send-email", func(ctx context.Context) (any, error) {
					return nil, sendEmail(ctx, Email{
						To:      data.Recipient,
						Subject: draft.Subject,
						Body:    draft.Body,
					})
				})
				if err != nil {
					return nil, err
				}
				return map[string]any{"status": "approved", "action": "email_sent"}, nil
			}

			reason := approval.Data.Reason
			if reason == "" {
				reason = "No reason provided"
			}
			return map[string]any{
				"status": "rejected",
				"reason": reason,
				"action": "email_not_sent",
			}, nil
		},
	)
}
```

The `match` field (an equivalent `if` expression in Python and Go) 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:**

```typescript {{ title: "TypeScript" }}
// 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" });
});
```

```python {{ title: "Python" }}
import json

import fastapi
import inngest

inngest_client = inngest.Inngest(app_id="my-app")
app = fastapi.FastAPI()

# NOTE - This is pseudo code for handling Slack interactions, please review
# their docs for implementation
@app.post("/api/slack/interactions")
async def slack_interactions(request: fastapi.Request) -> dict[str, str]:
    form = await request.form()
    payload = json.loads(str(form["payload"]))
    action = payload["actions"][0]
    value = json.loads(action["value"])

    # Send the event using the client
    await inngest_client.send(
        inngest.Event(
            name="agent/approval.response",
            data={
                "approvalId": value["approvalId"],
                "approved": value["approved"],
                "respondedBy": payload["user"]["id"],
                "reason": None if value["approved"] else "Rejected via Slack",
            },
        )
    )

    return {"text": "✅ Approved" if value["approved"] else "❌ Rejected"}
```

```go {{ title: "Go" }}
import (
	"encoding/json"
	"net/http"

	"github.com/inngest/inngestgo"
)

// NOTE - This is pseudo code for handling Slack interactions, please review their docs for implementation
func SlackInteractions(client inngestgo.Client) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var payload struct {
			User    struct{ ID string } `json:"user"`
			Actions []struct {
				Value string `json:"value"`
			} `json:"actions"`
		}
		if err := json.Unmarshal([]byte(r.FormValue("payload")), &payload); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		var value struct {
			ApprovalID string `json:"approvalId"`
			Approved   bool   `json:"approved"`
		}
		if err := json.Unmarshal([]byte(payload.Actions[0].Value), &value); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		reason := ""
		if !value.Approved {
			reason = "Rejected via Slack"
		}

		// Send the event using the client
		_, err := client.Send(r.Context(), inngestgo.Event{
			Name: "agent/approval.response",
			Data: map[string]any{
				"approvalId":  value.ApprovalID,
				"approved":    value.Approved,
				"respondedBy": payload.User.ID,
				"reason":      reason,
			},
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		text := "❌ Rejected"
		if value.Approved {
			text = "✅ Approved"
		}
		_ = json.NewEncoder(w).Encode(map[string]string{"text": text})
	}
}
```

**From a custom dashboard API:**

```typescript {{ title: "TypeScript" }}
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" });
});
```

```python {{ title: "Python" }}
import fastapi
import inngest
import pydantic

inngest_client = inngest.Inngest(app_id="my-app")
app = fastapi.FastAPI()

class ApprovalBody(pydantic.BaseModel):
    approved: bool
    reason: str | None = None

@app.post("/api/approvals/{approval_id}/respond")
async def respond(
    approval_id: str, body: ApprovalBody, request: fastapi.Request
) -> dict[str, str]:
    await inngest_client.send(
        inngest.Event(
            name="agent/approval.response",
            data={
                "approvalId": approval_id,
                "approved": body.approved,
                "respondedBy": current_user_id(request),
                "reason": body.reason,
            },
        )
    )

    return {"status": "response_recorded"}
```

```go {{ title: "Go" }}
import (
	"encoding/json"
	"net/http"

	"github.com/inngest/inngestgo"
)

// Register with: mux.HandleFunc("POST /api/approvals/{approvalId}/respond", RespondToApproval(client))
func RespondToApproval(client inngestgo.Client) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		approvalID := r.PathValue("approvalId")
		var body struct {
			Approved bool   `json:"approved"`
			Reason   string `json:"reason"`
		}
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		_, err := client.Send(r.Context(), inngestgo.Event{
			Name: "agent/approval.response",
			Data: map[string]any{
				"approvalId":  approvalID,
				"approved":    body.Approved,
				"respondedBy": currentUserID(r),
				"reason":      body.Reason,
			},
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		_ = json.NewEncoder(w).Encode(map[string]string{"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:

```typescript {{ title: "TypeScript" }}
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 };
```

```python {{ title: "Python" }}
approval = await ctx.step.wait_for_event(
    "wait-for-approval",
    event="agent/approval.response",
    if_exp="async.data.approvalId == event.data.approvalId",
    timeout=datetime.timedelta(hours=24),
)

if approval is None:
    # TIMEOUT: No response within the window
    return {"status": "timed_out"}

if approval.data.get("approved"):
    # APPROVED: Proceed with the action
    result = await ctx.step.run(
        "execute-action", perform_action, approval.data
    )
    return {"status": "approved", "result": result}

# REJECTED
return {"status": "rejected", "reason": approval.data.get("reason")}
```

```go {{ title: "Go" }}
approval, err := step.WaitForEvent[inngestgo.GenericEvent[ApprovalResponse]](ctx, "wait-for-approval", step.WaitForEventOpts{
	Event:   "agent/approval.response",
	If:      inngestgo.StrPtr("async.data.approvalId == event.data.approvalId"),
	Timeout: 24 * time.Hour,
})

if errors.Is(err, step.ErrEventNotReceived) {
	// TIMEOUT: No response within the window
	return map[string]any{"status": "timed_out"}, nil
}
if err != nil {
	return nil, err
}

if approval.Data.Approved {
	// APPROVED: Proceed with the action
	result, err := step.Run(ctx, "execute-action", func(ctx context.Context) (any, error) {
		return performAction(ctx, approval.Data)
	})
	if err != nil {
		return nil, err
	}
	return map[string]any{"status": "approved", "result": result}, nil
}

// REJECTED
return map[string]any{"status": "rejected", "reason": approval.Data.Reason}, nil
```

### Choose a timeout strategy

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

| Strategy               | When to use                                          | Implementation                                         |
| ---------------------- | ---------------------------------------------------- | ------------------------------------------------------ |
| **Auto-reject**        | High-risk actions (delete, deploy, send to external) | Return early with `status: "timed_out"`                |
| **Auto-approve**       | Low-risk, time-sensitive actions                     | Proceed if `!approval`, same as approved path          |
| **Escalate**           | Actions that *must* get a response                   | Notify a different reviewer, then `waitForEvent` again |
| **Retry notification** | Human might have missed the first message            | Re-notify, then wait with a new timeout                |

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

```typescript {{ title: "TypeScript" }}
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);
}
```

```python {{ title: "Python" }}
approval = await ctx.step.wait_for_event(
    "wait-for-approval",
    event="agent/approval.response",
    if_exp="async.data.approvalId == event.data.approvalId",
    timeout=datetime.timedelta(hours=4),
)

if approval is None:

    async def escalate_to_manager() -> None:
        await send_slack_dm(
            user_id=str(ctx.event.data["escalationContact"]),
            text=(
                "⚠️ Approval needed — original reviewer didn't respond "
                f"in 4 hours.\n\n{action_summary}"
            ),
        )

    await ctx.step.run("escalate-to-manager", escalate_to_manager)

    escalated_approval = await ctx.step.wait_for_event(
        "wait-for-escalation",
        event="agent/approval.response",
        if_exp="async.data.approvalId == event.data.approvalId",
        timeout=datetime.timedelta(hours=4),
    )

    if escalated_approval is None:
        return {"status": "timed_out", "escalated": True}

    return handle_approval(escalated_approval)
```

```go {{ title: "Go" }}
approval, err := step.WaitForEvent[inngestgo.GenericEvent[ApprovalResponse]](ctx, "wait-for-approval", step.WaitForEventOpts{
	Event:   "agent/approval.response",
	If:      inngestgo.StrPtr("async.data.approvalId == event.data.approvalId"),
	Timeout: 4 * time.Hour,
})

if errors.Is(err, step.ErrEventNotReceived) {
	_, err := step.Run(ctx, "escalate-to-manager", func(ctx context.Context) (any, error) {
		return nil, sendSlackDM(
			ctx,
			input.Event.Data.EscalationContact,
			fmt.Sprintf("⚠️ Approval needed — original reviewer didn't respond in 4 hours.\n\n%s", actionSummary),
		)
	})
	if err != nil {
		return nil, err
	}

	escalatedApproval, err := step.WaitForEvent[inngestgo.GenericEvent[ApprovalResponse]](ctx, "wait-for-escalation", step.WaitForEventOpts{
		Event:   "agent/approval.response",
		If:      inngestgo.StrPtr("async.data.approvalId == event.data.approvalId"),
		Timeout: 4 * time.Hour,
	})
	if errors.Is(err, step.ErrEventNotReceived) {
		return map[string]any{"status": "timed_out", "escalated": true}, nil
	}
	if err != nil {
		return nil, err
	}

	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](/docs-markdown/durable-execution/durable-agents/agent-tool-loops):

```typescript {{ title: "TypeScript" }}
const APPROVAL_REQUIRED_TOOLS = ["send_email", "delete_record", "run_sql", "deploy"];

export const agentWithApproval = inngest.createFunction(
  { id: "agent-with-approval", triggers: { 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" };
  }
);
```

````python {{ title: "Python" }}
import datetime
import json
import typing

import inngest

inngest_client = inngest.Inngest(app_id="my-app")

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

@inngest_client.create_function(
    fn_id="agent-with-approval",
    trigger=inngest.TriggerEvent(event="agent/task.received"),
)
async def agent_with_approval(ctx: inngest.Context) -> dict[str, typing.Any]:
    messages: list[dict[str, typing.Any]] = [
        {"role": "user", "content": ctx.event.data["task"]}
    ]
    iterations = 0

    while iterations < 20:
        iterations += 1

        llm_response = await ctx.step.run(
            "think", call_llm, messages, all_tools
        )

        if not llm_response["tool_calls"]:
            return {"response": llm_response["text"], "iterations": iterations}

        for tool_call in llm_response["tool_calls"]:
            name = tool_call["name"]
            if name in APPROVAL_REQUIRED_TOOLS:
                # Create a unique approval ID that will not be re-used
                approval_id = f"{ctx.event.data['taskId']}-{iterations}-{name}"

                async def request_approval() -> None:
                    args = json.dumps(tool_call["arguments"], indent=2)
                    await send_slack_message(
                        channel="#agent-approvals",
                        text="\n".join(
                            [
                                f"🔒 *Agent wants to execute: `{name}`*",
                                f"```{args}```",
                            ]
                        ),
                    )

                await ctx.step.run(
                    f"request-approval-{approval_id}", request_approval
                )

                approval = await ctx.step.wait_for_event(
                    f"wait-approval-{approval_id}",
                    event="agent/approval.response",
                    if_exp="async.data.approvalId == event.data.approvalId",
                    timeout=datetime.timedelta(hours=4),
                )

                if approval is None or not approval.data.get("approved"):
                    reason = (
                        approval and approval.data.get("reason")
                    ) or "No response / timed out"
                    messages.append(
                        {
                            "role": "tool",
                            "content": (
                                "Tool call rejected by human reviewer. "
                                f"Reason: {reason}. "
                                "Choose a different approach."
                            ),
                        }
                    )
                    continue

            result = await ctx.step.run(
                f"tool-{name}", execute_tool, name, tool_call["arguments"]
            )

            messages.append({"role": "tool", "content": result})

    return {"status": "max_iterations_reached"}
````

````go {{ title: "Go" }}
import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"time"

	"github.com/inngest/inngestgo"
	"github.com/inngest/inngestgo/step"
)

var approvalRequiredTools = []string{"send_email", "delete_record", "run_sql", "deploy"}

type TaskReceived struct {
	Task   string `json:"task"`
	TaskID string `json:"taskId"`
}

func AgentWithApproval(client inngestgo.Client) (inngestgo.ServableFunction, error) {
	return inngestgo.CreateFunction(
		client,
		inngestgo.FunctionOpts{ID: "agent-with-approval"},
		inngestgo.EventTrigger("agent/task.received", nil),
		func(ctx context.Context, input inngestgo.Input[TaskReceived]) (any, error) {
			messages := []Message{{Role: "user", Content: input.Event.Data.Task}}
			iterations := 0

			for iterations < 20 {
				iterations++

				llmResponse, err := step.Run(ctx, "think", func(ctx context.Context) (LLMResponse, error) {
					return callLLM(ctx, messages, allTools)
				})
				if err != nil {
					return nil, err
				}

				if len(llmResponse.ToolCalls) == 0 {
					return map[string]any{"response": llmResponse.Text, "iterations": iterations}, nil
				}

				for _, toolCall := range llmResponse.ToolCalls {
					if slices.Contains(approvalRequiredTools, toolCall.Name) {
						// Create a unique approval ID that will not be re-used
						approvalID := fmt.Sprintf("%s-%d-%s", input.Event.Data.TaskID, iterations, toolCall.Name)

						_, err := step.Run(ctx, "request-approval-"+approvalID, func(ctx context.Context) (any, error) {
							args, _ := json.MarshalIndent(toolCall.Arguments, "", "  ")
							return nil, sendSlackMessage(ctx, SlackMessage{
								Channel: "#agent-approvals",
								Text:    fmt.Sprintf("🔒 *Agent wants to execute: `%s`*\n```%s```", toolCall.Name, args),
							})
						})
						if err != nil {
							return nil, err
						}

						approval, err := step.WaitForEvent[inngestgo.GenericEvent[ApprovalResponse]](
							ctx,
							"wait-approval-"+approvalID,
							step.WaitForEventOpts{
								Event:   "agent/approval.response",
								If:      inngestgo.StrPtr("async.data.approvalId == event.data.approvalId"),
								Timeout: 4 * time.Hour,
							},
						)
						if err != nil && !errors.Is(err, step.ErrEventNotReceived) {
							return nil, err
						}

						if !approval.Data.Approved {
							reason := approval.Data.Reason
							if reason == "" {
								reason = "No response / timed out"
							}
							messages = append(messages, Message{
								Role:    "tool",
								Content: fmt.Sprintf("Tool call rejected by human reviewer. Reason: %s. Choose a different approach.", reason),
							})
							continue
						}
					}

					result, err := step.Run(ctx, "tool-"+toolCall.Name, func(ctx context.Context) (string, error) {
						return executeTool(ctx, toolCall.Name, toolCall.Arguments)
					})
					if err != nil {
						return nil, err
					}

					messages = append(messages, Message{Role: "tool", Content: result})
				}
			}

			return map[string]any{"status": "max_iterations_reached"}, nil
		},
	)
}
````

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:

```typescript {{ title: "TypeScript" }}
export const multiApprovalWorkflow = inngest.createFunction(
  { id: "multi-approval-publish", triggers: { 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"] };
  }
);
```

```python {{ title: "Python" }}
import datetime
import typing

import inngest

inngest_client = inngest.Inngest(app_id="my-app")

@inngest_client.create_function(
    fn_id="multi-approval-publish",
    trigger=inngest.TriggerEvent(event="content/publish.requested"),
)
async def multi_approval_workflow(
    ctx: inngest.Context,
) -> dict[str, typing.Any]:
    content_id = str(ctx.event.data["contentId"])

    content = await ctx.step.run(
        "generate-content", generate_content, content_id
    )

    # --- Gate 1: Editorial approval ---
    async def request_editorial_review() -> None:
        await send_slack_message(
            channel="#editorial",
            text=f"📝 Review needed: {content['title']}\n\n{content['preview']}",
        )

    await ctx.step.run("request-editorial-review", request_editorial_review)

    editorial_approval = await ctx.step.wait_for_event(
        "wait-editorial",
        event="content/review.completed",
        if_exp="async.data.contentId == event.data.contentId",
        timeout=datetime.timedelta(hours=48),
    )

    if editorial_approval is None or not editorial_approval.data.get(
        "approved"
    ):
        return {"status": "rejected_by_editorial"}

    # --- Gate 2: Legal approval ---
    async def request_legal_review() -> None:
        await send_slack_message(
            channel="#legal-review",
            text=(
                f"⚖️ Legal review needed: {content['title']}\n\n"
                "Editorial approved. Awaiting legal sign-off."
            ),
        )

    await ctx.step.run("request-legal-review", request_legal_review)

    legal_approval = await ctx.step.wait_for_event(
        "wait-legal",
        event="content/legal-review.completed",
        if_exp="async.data.contentId == event.data.contentId",
        timeout=datetime.timedelta(hours=72),
    )

    if legal_approval is None or not legal_approval.data.get("approved"):
        return {"status": "rejected_by_legal"}

    # --- Both gates passed ---
    await ctx.step.run("publish", publish_content, content)

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

```go {{ title: "Go" }}
import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/inngest/inngestgo"
	"github.com/inngest/inngestgo/step"
)

type PublishRequested struct {
	ContentID string `json:"contentId"`
}

type ReviewCompleted struct {
	ContentID string `json:"contentId"`
	Approved  bool   `json:"approved"`
}

func MultiApprovalWorkflow(client inngestgo.Client) (inngestgo.ServableFunction, error) {
	return inngestgo.CreateFunction(
		client,
		inngestgo.FunctionOpts{ID: "multi-approval-publish"},
		inngestgo.EventTrigger("content/publish.requested", nil),
		func(ctx context.Context, input inngestgo.Input[PublishRequested]) (any, error) {
			contentID := input.Event.Data.ContentID

			content, err := step.Run(ctx, "generate-content", func(ctx context.Context) (Content, error) {
				return generateContent(ctx, contentID)
			})
			if err != nil {
				return nil, err
			}

			// --- Gate 1: Editorial approval ---
			_, err = step.Run(ctx, "request-editorial-review", func(ctx context.Context) (any, error) {
				return nil, sendSlackMessage(ctx, SlackMessage{
					Channel: "#editorial",
					Text:    fmt.Sprintf("📝 Review needed: %s\n\n%s", content.Title, content.Preview),
				})
			})
			if err != nil {
				return nil, err
			}

			editorialApproval, err := step.WaitForEvent[inngestgo.GenericEvent[ReviewCompleted]](ctx, "wait-editorial", step.WaitForEventOpts{
				Event:   "content/review.completed",
				If:      inngestgo.StrPtr("async.data.contentId == event.data.contentId"),
				Timeout: 48 * time.Hour,
			})
			if err != nil && !errors.Is(err, step.ErrEventNotReceived) {
				return nil, err
			}

			if !editorialApproval.Data.Approved {
				return map[string]any{"status": "rejected_by_editorial"}, nil
			}

			// --- Gate 2: Legal approval ---
			_, err = step.Run(ctx, "request-legal-review", func(ctx context.Context) (any, error) {
				return nil, sendSlackMessage(ctx, SlackMessage{
					Channel: "#legal-review",
					Text:    fmt.Sprintf("⚖️ Legal review needed: %s\n\nEditorial approved. Awaiting legal sign-off.", content.Title),
				})
			})
			if err != nil {
				return nil, err
			}

			legalApproval, err := step.WaitForEvent[inngestgo.GenericEvent[ReviewCompleted]](ctx, "wait-legal", step.WaitForEventOpts{
				Event:   "content/legal-review.completed",
				If:      inngestgo.StrPtr("async.data.contentId == event.data.contentId"),
				Timeout: 72 * time.Hour,
			})
			if err != nil && !errors.Is(err, step.ErrEventNotReceived) {
				return nil, err
			}

			if !legalApproval.Data.Approved {
				return map[string]any{"status": "rejected_by_legal"}, nil
			}

			// --- Both gates passed ---
			_, err = step.Run(ctx, "publish", func(ctx context.Context) (any, error) {
				return nil, publishContent(ctx, content)
			})
			if err != nil {
				return nil, err
			}

			return map[string]any{"status": "published", "approvals": []string{"editorial", "legal"}}, nil
		},
	)
}
```

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

- [Build an agent tool loop](/docs-markdown/durable-execution/durable-agents/agent-tool-loops) with `step.run()`
- [Delegate subtasks to child agents](/docs-markdown/durable-execution/durable-agents/sub-agent-delegation) with `step.invoke()`
- Learn more about [`step.waitForEvent()`](/docs-markdown/durable-execution/primitives/step-waitforevent) in the reference docs
- [Combine this approach with our "realtime" feature](/docs-markdown/examples/realtime#human-in-the-loop-bi-directional-workflows) for approvals from the UI
- [Why durable execution matters for HITL](/blog/durable-execution-key-to-harnessing-ai-agents?ref=docs-ai-patterns-human-in-the-loop) — how suspend/resume makes approval gates possible