Introduction: The Human Touch in Automated Systems

In the world of AI and automation, achieving fully autonomous systems is often the goal, but not always the best or safest path. Many critical workflows, especially those involving sensitive data, creative output, or high-stakes decisions, benefit immensely from human oversight. This is where Human-in-the-Loop (HITL) workflows come into play. They allow automated processes to pause, seek human input, and then continue based on that decision, ensuring accuracy, compliance, and ethical considerations.

Coupled with HITL, real-time updates are crucial. When a human is involved, they need immediate feedback on the workflow’s status or to quickly provide their input. Real-time updates bridge the gap between durable backend processes and responsive user interfaces, making collaborative workflows feel seamless and efficient.

In this chapter, we’ll explore how Trigger.dev empowers you to build robust HITL workflows that can gracefully wait for human interaction and how to design systems that provide real-time updates. You’ll learn the core mechanics of pausing and resuming durable jobs based on external events, a foundational skill for creating sophisticated AI agents and collaborative business processes. We assume you’re familiar with the basics of creating Trigger.dev jobs and have your development environment set up from previous chapters.

Core Concepts: Weaving Humans into Automated Flows

Building systems that combine the speed and scale of automation with the nuanced judgment of humans requires careful orchestration. Trigger.dev provides the durable execution primitives needed to make this possible without complex state management on your part.

What is Human-in-the-Loop (HITL)?

📌 Key Idea: Human-in-the-Loop (HITL) refers to workflows where a human intervenes at specific points to review, approve, or provide input to an otherwise automated process.

Imagine an AI agent generating marketing copy. While the AI can produce content quickly, a human editor needs to review it for tone, accuracy, and brand compliance before it’s published. This review step is a classic example of HITL.

Why is HITL so important, especially with AI agents?

  • Accuracy and Quality: Humans can catch errors, biases, or subtle nuances that AI models might miss.
  • Ethical Oversight: For sensitive applications, human review ensures decisions align with ethical guidelines and prevent unintended consequences.
  • Compliance and Regulation: Many industries require human approval for certain actions to meet legal or regulatory standards.
  • Handling Edge Cases: AI models perform well on typical data, but humans excel at handling novel or ambiguous situations.
  • Continuous Improvement: Human feedback can be used to retrain and improve AI models over time.

The Role of Real-time Updates

Real-time updates ensure that when a workflow is waiting for human input, the human interface (like a web dashboard or an email notification) reflects the current state accurately and immediately.

For instance, when a job requests human approval, the dashboard should instantly show “Pending Approval.” Once the human acts, it should update to “Approved” or “Rejected” without delay. This responsiveness is critical for:

  • User Experience: No one likes waiting or refreshing pages manually.
  • Transparency: Users understand where the workflow stands.
  • Efficiency: Humans can react faster when notified promptly.

Trigger.dev itself is a durable execution platform, meaning it orchestrates backend jobs. While it doesn’t directly provide real-time frontend updates (like websockets), it provides the robust eventing mechanism that allows your frontend to listen for or poll status changes. When a Trigger.dev job sends an event for human interaction, your frontend can be designed to pick up that event or the resulting state change.

Trigger.dev’s Approach to HITL: Durable Waits

Trigger.dev makes HITL possible through its durable execution capabilities, specifically the ability for a job to pause and wait for an external event to resume. This means:

  1. A job can perform some tasks.
  2. It can then await client.waitForEvent() for a specific event.
  3. The workflow is persisted and doesn’t consume compute resources while waiting.
  4. An external system (like your frontend, an email service, or another backend) can then send an event back to Trigger.dev.
  5. When the matching event arrives, the workflow automatically resumes from where it left off, processing the event’s payload.

This mechanism ensures that even if your server restarts or goes down, the workflow state is maintained, and it will pick up exactly when the human input arrives.

Here’s a simplified flow of a HITL workflow:

flowchart TD User_Action[User Initiates Task] --> Trigger_Job[Trigger Job Start] Trigger_Job --> AI_Process[AI Generates Content] AI_Process --> Request_Approval[Request Human Approval] Request_Approval --> Human_Review[Human Reviews and Decides] Human_Review --> Send_Approval_Event[Send Approval Event] Send_Approval_Event --> Trigger_Job_Resume[Trigger Job Resume] Trigger_Job_Resume --> Final_Action[Publish or Reject]

Building a Human Approval Workflow (Step-by-Step)

Let’s build a practical example: an AI-generated blog post needs human approval before it’s published. We’ll simulate the AI generation and the human interaction.

Prerequisites

Make sure you have a Trigger.dev project set up. If you’re following along, you should have a trigger.config.ts and an src/jobs directory.

Step 1: Create a New Job for Content Approval

First, let’s create a new job file. We’ll call it src/jobs/contentApproval.ts.

Create the file:

touch src/jobs/contentApproval.ts

Open src/jobs/contentApproval.ts and add the basic job structure:

// src/jobs/contentApproval.ts
import { client } from "../trigger";
import { eventTrigger } from "@trigger.dev/sdk";

client.defineJob({
  id: "content-approval-workflow",
  name: "AI Content Approval Workflow",
  version: "1.0.0",
  trigger: eventTrigger({
    name: "content.generate",
    schema: {
      type: "object",
      properties: {
        topic: { type: "string" },
        draftId: { type: "string" },
      },
      required: ["topic", "draftId"],
      additionalProperties: false,
    },
  }),
  run: async (payload, io, ctx) => {
    // This is where our workflow logic will go
    io.logger.info("Starting AI content approval workflow", { payload });

    // Simulate AI content generation
    await io.wait("waiting-for-ai", 5); // Simulate a 5-second AI generation
    const aiGeneratedContent = `Draft blog post about "${payload.topic}"... This is placeholder content generated by AI.`;

    io.logger.info("AI content generated.", { content: aiGeneratedContent });

    // We'll add human approval logic here
    // ...
  },
});
  • Explanation:
    • We import client and eventTrigger.
    • id and name identify our job.
    • The trigger is an eventTrigger named content.generate. This means our workflow will start when an event with this name is sent to Trigger.dev.
    • The schema defines the expected payload for this event, which includes topic and draftId.
    • Inside run, we simulate AI content generation using io.wait. io.wait is a durable way to pause a workflow for a specified duration.

Step 2: Requesting Human Approval

Now, let’s introduce the human interaction. The job will “send” an event to notify an external system (which would be your human interface, e.g., a web dashboard or an email system) that approval is needed. Then, it will durably wait for that system to send an approval event back.

Add the following code inside the run function, right after io.logger.info("AI content generated...", { content: aiGeneratedContent });:

    // ... (previous code in run function)

    io.logger.info("Requesting human approval for content.", { draftId: payload.draftId });

    // 🧠 Important: We're sending an event to an *external system*.
    // This event would typically trigger a notification (email, Slack, dashboard update)
    // in your application, prompting a human to review.
    // The `id` is crucial for linking this specific approval request to the response.
    const approvalRequestEventId = `content-approval-${payload.draftId}`;

    // You might send this event to a specific queue or topic for your external system
    // For this example, we're just logging it, but in a real app, this would
    // be `io.sendEvent("external.system.approval.request", { ... })`
    // or an HTTP call to your UI's API.
    io.logger.debug("Simulating sending approval request to external system.", {
        eventId: approvalRequestEventId,
        content: aiGeneratedContent,
        topic: payload.topic,
    });

    // ⚡ Quick Note: The `io.waitForEvent` call below is the actual durable wait.
    // The `io.logger.debug` above is just a placeholder for how you'd notify
    // a human interface.

    // ... (rest of the run function)
  • Explanation:
    • We generate a unique approvalRequestEventId using the draftId. This ID is vital because it allows us to link the specific approval request to its corresponding response event.
    • We use io.logger.debug to simulate sending a notification to an external system. In a real application, this might involve:
      • Calling io.sendEvent to a different Trigger.dev job that handles sending emails or Slack messages.
      • Making an io.runTask HTTP request to an API endpoint in your frontend application to update a dashboard.
    • The core idea is that something external needs to be notified and given the approvalRequestEventId so it can send back the corresponding response.

Step 3: Waiting for Approval

Now for the magic! The job will pause and wait for an event named content.approved with a matching id.

Add the following code after the io.logger.debug call in the run function:

    // ... (previous code in run function)

    let approvalResult;
    try {
      // 🧠 Important: The job will durably wait here for an event named "content.approved"
      // with a matching `id` (this links it to our specific approval request).
      // We also set a timeout of 1 hour (3600 seconds) for the human to respond.
      approvalResult = await io.waitForEvent<{ approved: boolean; reason?: string }>(
        "human-approval", // This is the unique name for this specific wait point
        {
          name: "content.approved",
          id: approvalRequestEventId, // Match the ID we generated earlier
          timeoutInSeconds: 3600, // Wait for up to 1 hour
        }
      );
    } catch (error) {
      if (error instanceof Error && error.message.includes("Timed out")) {
        io.logger.warn("Human approval timed out.", { draftId: payload.draftId });
        // Handle timeout: e.g., send a reminder, automatically reject, or escalate
        await io.runTask("send-timeout-alert", async () => {
          // Simulate sending a timeout alert
          io.logger.error(`Approval for draft ${payload.draftId} timed out.`);
        });
        return; // Exit the workflow or take further action
      }
      throw error; // Re-throw other errors
    }

    // ... (rest of the run function)
  • Explanation:
    • await io.waitForEvent() is the core function for durable waiting.
    • The first argument, "human-approval", is a unique key for this specific wait point within your workflow. This is useful for observability in the Trigger.dev dashboard.
    • The second argument is an options object:
      • name: "content.approved": This tells Trigger.dev to wait for an incoming event with this specific name.
      • id: approvalRequestEventId: This is crucial! It ensures the workflow only resumes when an event with this exact ID is received. This prevents one approval event from accidentally resuming the wrong workflow instance.
      • timeoutInSeconds: 3600: If no matching event arrives within 1 hour, waitForEvent will throw a timeout error. We wrap this in a try...catch block to handle the timeout gracefully.
    • If a timeout occurs, we log a warning, simulate sending an alert, and return to stop the workflow or implement further retry/escalation logic.

Step 4: Processing the Decision

Once the content.approved event is received (or timed out), the workflow resumes. We can then check the approvalResult payload to determine the human’s decision.

Add the following code after the try...catch block in the run function:

    // ... (previous code in run function)

    if (approvalResult.payload.approved) {
      io.logger.info("Human approved the content!", { draftId: payload.draftId });
      // In a real scenario, you'd publish the content here
      await io.runTask("publish-content", async () => {
        io.logger.info(`Publishing content for topic: ${payload.topic}`);
        // Simulate publishing
        await new Promise(resolve => setTimeout(resolve, 2000));
      });
      io.logger.info("Content published successfully.");
    } else {
      io.logger.warn("Human rejected the content.", {
        draftId: payload.draftId,
        reason: approvalResult.payload.reason,
      });
      // In a real scenario, you'd handle rejection (e.g., notify author, send back for revision)
      await io.runTask("handle-rejection", async () => {
        io.logger.info(`Notifying author about rejection for topic: ${payload.topic}`);
        // Simulate notification
        await new Promise(resolve => setTimeout(resolve, 1000));
      });
      io.logger.info("Content rejection handled.");
    }

    io.logger.info("AI content approval workflow completed.");
  },
});
  • Explanation:
    • We check approvalResult.payload.approved. The content.approved event we’re waiting for is expected to have a boolean approved property in its payload.
    • Based on the approved status, we branch the workflow:
      • If true, we simulate publishing the content.
      • If false, we simulate handling the rejection, potentially including a reason from the human.
    • io.runTask is used for these side effects, ensuring they are retried if transient failures occur.

The Complete Job Code

For context, here’s the full src/jobs/contentApproval.ts file:

// src/jobs/contentApproval.ts
import { client } from "../trigger";
import { eventTrigger } from "@trigger.dev/sdk";

client.defineJob({
  id: "content-approval-workflow",
  name: "AI Content Approval Workflow",
  version: "1.0.0",
  trigger: eventTrigger({
    name: "content.generate",
    schema: {
      type: "object",
      properties: {
        topic: { type: "string" },
        draftId: { type: "string" },
      },
      required: ["topic", "draftId"],
      additionalProperties: false,
    },
  }),
  run: async (payload, io, ctx) => {
    io.logger.info("Starting AI content approval workflow", { payload });

    // Simulate AI content generation
    await io.wait("waiting-for-ai", 5); // Simulate a 5-second AI generation
    const aiGeneratedContent = `Draft blog post about "${payload.topic}"... This is placeholder content generated by AI.`;

    io.logger.info("AI content generated.", { content: aiGeneratedContent });

    io.logger.info("Requesting human approval for content.", { draftId: payload.draftId });

    const approvalRequestEventId = `content-approval-${payload.draftId}`;

    io.logger.debug("Simulating sending approval request to external system.", {
        eventId: approvalRequestEventId,
        content: aiGeneratedContent,
        topic: payload.topic,
    });

    let approvalResult;
    try {
      approvalResult = await io.waitForEvent<{ approved: boolean; reason?: string }>(
        "human-approval",
        {
          name: "content.approved",
          id: approvalRequestEventId,
          timeoutInSeconds: 3600, // Wait for up to 1 hour
        }
      );
    } catch (error) {
      if (error instanceof Error && error.message.includes("Timed out")) {
        io.logger.warn("Human approval timed out.", { draftId: payload.draftId });
        await io.runTask("send-timeout-alert", async () => {
          io.logger.error(`Approval for draft ${payload.draftId} timed out.`);
        });
        return;
      }
      throw error;
    }

    if (approvalResult.payload.approved) {
      io.logger.info("Human approved the content!", { draftId: payload.draftId });
      await io.runTask("publish-content", async () => {
        io.logger.info(`Publishing content for topic: ${payload.topic}`);
        await new Promise(resolve => setTimeout(resolve, 2000));
      });
      io.logger.info("Content published successfully.");
    } else {
      io.logger.warn("Human rejected the content.", {
        draftId: payload.draftId,
        reason: approvalResult.payload.reason,
      });
      await io.runTask("handle-rejection", async () => {
        io.logger.info(`Notifying author about rejection for topic: ${payload.topic}`);
        await new Promise(resolve => setTimeout(resolve, 1000));
      });
      io.logger.info("Content rejection handled.");
    }

    io.logger.info("AI content approval workflow completed.");
  },
});

Step 5: Simulating Real-time Feedback and Interaction

To test this, you need to:

  1. Start your Trigger.dev development server:

    npx trigger.dev@v4-beta dev
    
  2. Trigger the initial event: Go to the Trigger.dev dashboard (usually http://localhost:8888), find your AI Content Approval Workflow job, and click “Run Job”. Provide a payload like this:

    {
      "topic": "The Future of AI in Education",
      "draftId": "blog-post-123"
    }
    

    The job will start, simulate AI generation, and then enter a “WAITING” state. You’ll see this in the Trigger.dev dashboard.

  3. Simulate human approval: While the job is waiting, you need to send an event named content.approved with the correct id back to Trigger.dev.

    • The id should be content-approval-blog-post-123 (based on our draftId).
    • The payload needs { "approved": true } or { "approved": false, "reason": "Needs more examples." }.

    You can send this event using the Trigger.dev dashboard’s “Send Event” feature, or programmatically. Here’s how you’d send an event programmatically (e.g., from an API endpoint in your Next.js app that a human UI button calls):

    // Example of how an external system (e.g., a Next.js API route) would send the event
    import { TriggerClient } from "@trigger.dev/sdk";
    
    // You would initialize this client with your API key and project ID
    // For local dev, ensure TRIGGER_API_KEY and TRIGGER_PROJECT_ID are set in your .env
    const triggerClient = new TriggerClient({
      id: "my-app-worker", // A unique ID for your client sending events
      apiKey: process.env.TRIGGER_API_KEY!,
    });
    
    async function sendApprovalEvent(draftId: string, approved: boolean, reason?: string) {
      const eventId = `content-approval-${draftId}`;
      await triggerClient.sendEvent({
        name: "content.approved",
        id: eventId, // CRITICAL: This links back to the waiting workflow
        payload: {
          approved: approved,
          reason: reason,
        },
      });
      console.log(`Sent approval event for draft ${draftId}. Approved: ${approved}`);
    }
    
    // Call this function when a human approves:
    // sendApprovalEvent("blog-post-123", true);
    
    // Call this function when a human rejects:
    // sendApprovalEvent("blog-post-123", false, "Needs more real-world examples.");
    
    • Explanation:
      • We initialize a TriggerClient with your project’s apiKey and id.
      • triggerClient.sendEvent is used to send the content.approved event.
      • The id field is set to content-approval-blog-post-123, which matches the id our workflow is waiting for. This is how Trigger.dev knows which specific workflow instance to resume.
      • The payload contains the human’s decision (approved: boolean).

After sending the approval event, go back to the Trigger.dev dashboard and observe your workflow. It should immediately resume from its “WAITING” state and complete based on the approval decision you sent. This immediate resumption demonstrates the “real-time” aspect of the workflow reacting to external input.

Mini-Challenge: Add Rejection Handling

Your turn!

Challenge: Modify the contentApproval.ts workflow to include an additional step when the content is rejected. Specifically, if the content is rejected, instead of just logging a message, durable wait for a “revision completed” event, then restart the AI generation and approval process. This simulates a cycle where a human sends content back for revision.

Hint:

  • Inside the else block (where content is rejected), you’ll need another io.waitForEvent for a content.revision.completed event, again using the approvalRequestEventId to link it.
  • After the revision event is received, you can re-run the AI generation and re-request approval. You might need to wrap the AI generation and approval request logic in a function to easily call it again.
  • Consider adding a revisionCount to the job payload to prevent infinite loops.

What to observe/learn: You’ll see how to create complex, multi-stage human-in-the-loop workflows that can loop back to earlier stages based on human decisions. You’ll also further solidify your understanding of io.waitForEvent and event IDs.

Common Pitfalls & Troubleshooting

Working with human-in-the-loop and real-time updates introduces a few common challenges:

  1. Timeout Management:

    • Pitfall: Not setting a timeoutInSeconds for io.waitForEvent, or setting it too short/long. If there’s no timeout, a workflow could wait forever if the human never responds or the external event is never sent. If it’s too short, it might time out prematurely.
    • Troubleshooting: Always consider the expected human response time. Implement robust error handling for TimeoutError as shown in our example. You might want to send reminders before the timeout, escalate to another human, or automatically take a default action. Monitor your Trigger.dev dashboard for workflows stuck in “WAITING” states.
  2. Event ID Mismatches:

    • Pitfall: The id passed to io.waitForEvent does not exactly match the id of the incoming event. This is a very common mistake. The workflow will never resume because it’s waiting for a specific identifier that never arrives.
    • Troubleshooting: Double-check that the unique id generated and used in io.waitForEvent is exactly the same as the id sent with triggerClient.sendEvent. Use descriptive IDs that clearly link to the specific workflow instance and context (e.g., content-approval-${draftId}).
  3. External System Reliability:

    • Pitfall: The external system responsible for sending the approval event (e.g., your frontend API, an email service) fails or doesn’t send the event correctly.
    • Troubleshooting: Ensure your external system has proper error handling and logging when it attempts to send events to Trigger.dev. Use io.runTask for any external calls from your Trigger.dev job (e.g., sending an email notification) so that these actions are retried if they fail. Implement observability in your external system to confirm events are being sent.
  4. Debugging Durable Waits:

    • Pitfall: It can be confusing to debug a workflow that’s paused.
    • Troubleshooting: The Trigger.dev dashboard is your best friend. When a job is in a “WAITING” state, you can see which io.waitForEvent call it’s on, what event name and id it’s waiting for, and how much time is left on its timeout. This provides crucial context for debugging.

Summary

In this chapter, we’ve explored the essential concepts and practical implementation of Human-in-the-Loop (HITL) workflows and how to enable real-time updates using Trigger.dev.

Here are the key takeaways:

  • HITL Importance: Human intervention is critical for accuracy, ethics, compliance, and handling edge cases in many AI and automated systems.
  • Durable Waiting: Trigger.dev’s io.waitForEvent() function allows workflows to durably pause, persist their state, and resume only when a specific external event arrives.
  • Event IDs: Unique id values are crucial for linking a specific io.waitForEvent() call to its corresponding incoming event, preventing cross-talk between workflow instances.
  • Timeout Handling: Implementing timeoutInSeconds and handling TimeoutError in io.waitForEvent() is vital for robust workflows that don’t get stuck indefinitely.
  • Real-time Interaction: While Trigger.dev orchestrates the backend, its eventing model allows your external systems (like web UIs) to send and receive events, enabling responsive, real-time feedback for human users.

You now have the tools to design and implement collaborative workflows where AI and humans work together seamlessly. In the next chapter, we’ll dive into integrating Trigger.dev with other parts of your modern application stack, specifically focusing on Next.js, and explore how to deploy and manage these powerful workflows in production.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.