Building intelligent automation often means dealing with complex, multi-step processes that might involve external services, human intervention, and unpredictable delays. This is especially true for AI agents that interact with users and critical systems.

In this chapter, we’ll put all our Trigger.dev knowledge to the test by creating a practical, real-world AI-powered customer support agent. You’ll learn how to orchestrate an AI agent workflow that can classify user queries, retrieve information from a knowledge base, and even escalate to a human agent when needed, all while maintaining state across long-running, durable executions.

This project will solidify your understanding of Trigger.dev’s core features like AI agents, durable execution, human-in-the-loop workflows, and robust error handling. To get the most out of this chapter, you should be comfortable with basic Trigger.dev workflow setup, understand io.run and io.wait, and have a foundational grasp of asynchronous JavaScript/TypeScript.

Core Concepts: Architecting an Intelligent Support System

An effective AI customer support agent isn’t just a chatbot; it’s a sophisticated system capable of understanding intent, accessing information, and making decisions, often involving human oversight. Trigger.dev provides the perfect foundation for building such a system. Let’s break down the key architectural considerations.

The Role of AI in Customer Support

At its heart, an AI support agent aims to solve customer problems efficiently. This involves several critical capabilities:

  1. Understand User Intent: What is the customer trying to achieve or ask? AI models excel at discerning the underlying goal from natural language.
  2. Provide Relevant Information: Answer questions directly from a knowledge base or by synthesizing information from various sources.
  3. Automate Common Tasks: Handle routine requests like checking order status or updating personal details without human intervention, freeing up human agents.
  4. Escalate When Necessary: Identify situations too complex, ambiguous, or sensitive for AI and seamlessly hand them off to a human expert.

Human-in-the-Loop (HITL) for Trust and Accuracy

While AI is powerful, it’s not infallible. For critical applications like customer support, human oversight is essential. Human-in-the-Loop (HITL) workflows ensure that complex, ambiguous, or high-stakes decisions are reviewed and approved by a human.

Trigger.dev excels at HITL by allowing workflows to pause and wait for external events. For instance, a workflow can wait for a human to approve an AI-generated response or provide missing input. This creates robust systems where AI handles the mundane, and humans focus on value-added tasks that require empathy, nuanced judgment, or creative problem-solving.

Durable Workflows for Conversational AI

Customer interactions are rarely a single request-response. They often involve multi-turn conversations, where context from previous messages is crucial. Trigger.dev’s durable execution is a game-changer here:

  • State Persistence: The workflow’s state (e.g., conversation history, user ID, current query context) is automatically saved and restored. This means if the underlying server crashes or restarts, the conversation can pick up exactly where it left off, ensuring a seamless experience for the customer.
  • Long-Running Operations: Workflows can pause for minutes, hours, or even days (e.g., while waiting for human input or an external API response) without consuming active server resources. They resume precisely at the point of interruption.
  • Retries and Observability: If an external API call fails (e.g., to an AI service or a CRM), Trigger.dev automatically retries according to configured policies. You also get clear observability into each step of the conversation directly from the Trigger.dev dashboard.

Agent Workflow Diagram

Let’s visualize the flow of our AI customer support agent. This diagram illustrates the decision points and interactions between AI and human agents.

flowchart TD A[Customer Query] --> C[AI Classify Query] C --> D{Query Simple} D -->|Yes| E[Search Knowledge Base] D -->|No| F[Notify Human Agent] E --> P[Prepare Response] F --> P P --> J[Send Response]

This diagram shows how a customer query initiates a Trigger.dev workflow. The AI first classifies the query. Simple queries are handled by searching a knowledge base, while complex ones trigger a human escalation. Both paths eventually lead to a response being sent back to the customer, potentially after human review.

Step-by-Step Implementation: Building the Agent

We’ll build this agent incrementally, focusing on clarity and understanding each piece as we add it.

1. Project Setup

First, let’s create a new Trigger.dev project. We’ll be using the v4-beta, which is stable and feature-rich, with General Availability (GA) expected around May/June 2026. This version includes many enhancements for AI workflows.

Open your terminal and run the following command:

npx trigger.dev@v4-beta init

Follow the prompts:

  • What is your project name? Enter ai-support-agent.
  • Which framework are you using? Choose Next.js (or your preferred framework; the core Trigger.dev logic remains similar).
  • Do you want to use TypeScript? Yes.
  • Do you want to install dependencies? Yes.

Once the project is created and dependencies are installed, navigate into your new project directory:

cd ai-support-agent

We’ll also need an AI SDK, like OpenAI’s, and a simple way to simulate external services. We’ll use zod for robust schema validation, which is crucial when dealing with AI outputs.

npm install openai@4.x zod

zod is a schema declaration and validation library that is very useful for defining the structure of our AI responses or human input. openai@4.x is the latest major version as of 2026-05-20.

2. Configure Trigger.dev Client and Environment Variables

You’ll need your Trigger.dev API key to connect your application to the Trigger.dev cloud. In your .env file, add these variables:

# .env
TRIGGER_API_KEY="your_trigger_api_key_here"
OPENAI_API_KEY="your_openai_api_key_here"

Replace your_trigger_api_key_here with your actual key from the Trigger.dev dashboard. Similarly, replace your_openai_api_key_here with your OpenAI API key.

Next, open src/trigger.ts (or app/trigger.ts depending on your Next.js setup) and ensure your client is initialized correctly, referencing the API key from the environment.

// src/trigger.ts (or app/trigger.ts)
import { TriggerClient } from "@trigger.dev/sdk";

export const client = new TriggerClient({
  id: "ai-support-agent",
  apiKey: process.env.TRIGGER_API_KEY,
  apiUrl: process.env.TRIGGER_API_URL, // Optional: defaults to cloud.trigger.dev for the managed service
});

3. Define the AI Agent Workflow

Now, let’s create our main workflow. We’ll define it in a new file, src/jobs/customerSupportAgent.ts. This file will contain the entire logic for our AI agent.

Step 1: Initial Workflow Structure and AI Classification

We’ll start by defining the job and using an AI model to classify the incoming customer query. For simplicity, we’ll use the actual openai package for AI calls.

Create src/jobs/customerSupportAgent.ts and add the following code:

// src/jobs/customerSupportAgent.ts
import { client } from "@/trigger"; // Adjust path as needed for your project structure
import { OpenAI } from "openai"; // For actual OpenAI API calls
import { z } from "zod"; // For schema validation

// Initialize OpenAI client
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// Define the schema for the AI's classification response
const ClassificationSchema = z.object({
  type: z.enum(["simple_faq", "complex_issue", "billing_query", "feature_request", "unknown"]),
  keywords: z.array(z.string()).optional(),
  confidence: z.number().min(0).max(1),
  summary: z.string().optional(),
});

client.defineJob({
  id: "customer-support-agent",
  name: "AI Customer Support Agent",
  version: "1.0.0",
  enabled: true,
  trigger: client.defineEventTrigger({
    name: "customer.query",
    schema: z.object({
      userId: z.string(),
      query: z.string(),
      conversationId: z.string().optional(),
    }),
  }),
  run: async (payload, io, ctx) => {
    io.logger.info("Received customer query", payload);

    // 📌 Key Idea: Use io.runTask for external API calls like AI inference.
    // It provides automatic retries, observability in the dashboard, and error handling.
    const classification = await io.runTask(
      "classify-query",
      {
        name: "Classify Customer Query with AI",
        icon: "openai", // Use a relevant icon for dashboard visibility
        params: { query: payload.query },
      },
      async (task, params) => {
        // Here, we're calling the OpenAI API to classify the query.
        const response = await openai.chat.completions.create({
          model: "gpt-4o", // Using the latest capable model as of 2026-05-20
          messages: [
            { role: "system", content: `You are a helpful assistant that classifies customer queries into one of these categories: simple_faq, complex_issue, billing_query, feature_request, or unknown. Provide relevant keywords, a confidence score (0-1), and a brief summary. Respond strictly in JSON format.` },
            { role: "user", content: `Classify the following query: "${params.query}"` },
          ],
          response_format: { type: "json_object" },
          temperature: 0.1, // Lower temperature for more deterministic output
        });

        const rawResult = response.choices[0].message?.content;
        if (!rawResult) {
          throw new Error("AI classification failed to return content.");
        }
        const parsedResult = JSON.parse(rawResult);

        // 🧠 Important: Validate AI output using Zod for robustness.
        // This ensures the AI's response matches our expected structure.
        const validatedClassification = ClassificationSchema.parse(parsedResult);
        return validatedClassification;
      }
    );

    io.logger.info("Query classified", { classification });

    // For now, let's just log the classification. We'll add more logic next.
    return { status: "classified", classification };
  },
});

// Helper function to simulate knowledge base lookup
// ⚡ Real-world insight: This would typically hit a real database, search index,
// or a dedicated knowledge base API (e.g., Zendesk, Salesforce).
async function fetchKnowledgeBase(query: string): Promise<string | null> {
  const articles = {
    "shipping status": "Your order usually ships within 2-3 business days. You can track it here: [tracking link].",
    "refund policy": "Our refund policy allows returns within 30 days of purchase, provided the item is unused and in original packaging.",
    "account login": "If you're having trouble logging in, please try resetting your password. If that doesn't work, contact support.",
    "password reset": "To reset your password, visit our password recovery page and follow the instructions.",
  };
  const lowerQuery = query.toLowerCase();
  for (const keyword in articles) {
    if (lowerQuery.includes(keyword)) {
      return articles[keyword];
    }
  }
  return null;
}

Explanation:

  • We import TriggerClient, the OpenAI SDK, and zod.
  • An OpenAI client is initialized using the OPENAI_API_KEY from your environment.
  • ClassificationSchema uses Zod to define the expected structure of the AI’s output. This is crucial for ensuring our workflow only proceeds with valid and predictable data from the AI.
  • client.defineJob sets up our customer-support-agent job. It’s configured to trigger on a custom event named customer.query, which includes userId, query, and an optional conversationId.
  • Inside the run function, io.runTask is used to execute the AI classification. This ensures that the AI call is retried if it fails, and its execution (inputs, outputs, logs) is clearly visible in the Trigger.dev dashboard.
  • The AI prompt instructs gpt-4o to classify the query into predefined categories and respond in JSON. We then parse this JSON and validate it with Zod.
  • A fetchKnowledgeBase mock function is included, ready for later use to simulate retrieving answers from a knowledge base.

To test this initial setup, save the file, then run your Next.js development server:

npm run dev

In a separate terminal, trigger the customer.query event:

npx trigger.dev@v4-beta send customer.query '{"userId": "user-123", "query": "What is your refund policy?"}'

You should see the job run in your Trigger.dev dashboard. Navigate to the dashboard, find the execution, and observe the “Classify Customer Query with AI” step, complete with its input, output, and logs.

Step 2: Conditional Logic and Knowledge Base Lookup

Now, let’s add the logic to handle different classification types. If the AI classifies a query as simple_faq with high confidence, we’ll attempt to answer it from our mock knowledge base.

Modify the run function in src/jobs/customerSupportAgent.ts by adding the following if block after the io.logger.info("Query classified", { classification }); line:

// src/jobs/customerSupportAgent.ts (continued)
// ... (previous code for imports, client, OpenAI, ClassificationSchema, fetchKnowledgeBase)

client.defineJob({
  id: "customer-support-agent",
  name: "AI Customer Support Agent",
  version: "1.0.0",
  enabled: true,
  trigger: client.defineEventTrigger({
    name: "customer.query",
    schema: z.object({
      userId: z.string(),
      query: z.string(),
      conversationId: z.string().optional(),
    }),
  }),
  run: async (payload, io, ctx) => {
    io.logger.info("Received customer query", payload);

    const classification = await io.runTask(
      "classify-query",
      {
        name: "Classify Customer Query with AI",
        icon: "openai",
        params: { query: payload.query },
      },
      async (task, params) => {
        const response = await openai.chat.completions.create({
          model: "gpt-4o",
          messages: [
            { role: "system", content: `You are a helpful assistant that classifies customer queries into one of these categories: simple_faq, complex_issue, billing_query, feature_request, or unknown. Provide relevant keywords, a confidence score (0-1), and a brief summary. Respond strictly in JSON format.` },
            { role: "user", content: `Classify the following query: "${params.query}"` },
          ],
          response_format: { type: "json_object" },
          temperature: 0.1,
        });

        const rawResult = response.choices[0].message?.content;
        if (!rawResult) {
          throw new Error("AI classification failed to return content.");
        }
        const parsedResult = JSON.parse(rawResult);
        const validatedClassification = ClassificationSchema.parse(parsedResult);
        return validatedClassification;
      }
    );

    io.logger.info("Query classified", { classification });

    let responseToCustomer: string | null = null; // Initialize a variable to hold the final response

    // 🧠 Important: Conditional logic based on AI classification results.
    if (classification.type === "simple_faq" && classification.confidence > 0.7) {
      io.logger.info("Attempting to answer simple FAQ from knowledge base.");
      const kbAnswer = await io.runTask(
        "knowledge-base-lookup",
        {
          name: "Lookup Answer in Knowledge Base",
          icon: "database", // Use a database icon for this task
          params: { query: payload.query },
        },
        async (task, params) => {
          return await fetchKnowledgeBase(params.query);
        }
      );

      if (kbAnswer) {
        responseToCustomer = kbAnswer;
        io.logger.info("Found answer in KB.", { answer: kbAnswer });
      } else {
        io.logger.warn("Could not find answer in KB, will consider human escalation.");
        // If KB fails, responseToCustomer remains null, leading to escalation later
      }
    }

    // Next, we'll add human escalation if no automated response was found.
    // ... (rest of the run function will go here)
  },
});

Explanation:

  • We introduce a responseToCustomer variable, initialized to null. This variable will hold the answer if one is found, whether by AI or human.
  • An if statement checks if the AI classified the query as simple_faq with a confidence score above 0.7. This threshold helps filter out less certain AI answers.
  • If the conditions are met, io.runTask calls our fetchKnowledgeBase helper function. This task is also retriable and observable.
  • If an answer is found in the knowledge base, it’s assigned to responseToCustomer. If not, responseToCustomer remains null, signaling that further action (like human escalation) might be needed.

Test this by sending a simple query that should be in our mock knowledge base:

npx trigger.dev@v4-beta send customer.query '{"userId": "user-123", "query": "What is the refund policy?"}'

Then, send a query that won’t be found:

npx trigger.dev@v4-beta send customer.query '{"userId": "user-123", "query": "How do I connect my smart fridge?"}'

In the Trigger.dev dashboard, you should observe the “Lookup Answer in Knowledge Base” task. For the second query, it will likely complete without finding an answer, and the workflow will terminate (for now) after logging the warning.

Step 3: Human Escalation (Human-in-the-Loop)

What if the AI can’t confidently answer a query, or if the query is inherently complex and requires human judgment? This is where human intervention, or Human-in-the-Loop (HITL), comes into play.

Continue modifying the run function in src/jobs/customerSupportAgent.ts by adding the following if (!responseToCustomer) block after the previous if statement:

// src/jobs/customerSupportAgent.ts (continued)
// ... (previous code for imports, client, OpenAI, ClassificationSchema, fetchKnowledgeBase)

client.defineJob({
  id: "customer-support-agent",
  name: "AI Customer Support Agent",
  version: "1.0.0",
  enabled: true,
  trigger: client.defineEventTrigger({
    name: "customer.query",
    schema: z.object({
      userId: z.string(),
      query: z.string(),
      conversationId: z.string().optional(),
    }),
  }),
  run: async (payload, io, ctx) => {
    io.logger.info("Received customer query", payload);

    const classification = await io.runTask(
      "classify-query",
      {
        name: "Classify Customer Query with AI",
        icon: "openai",
        params: { query: payload.query },
      },
      async (task, params) => {
        const response = await openai.chat.completions.create({
          model: "gpt-4o",
          messages: [
            { role: "system", content: `You are a helpful assistant that classifies customer queries into one of these categories: simple_faq, complex_issue, billing_query, feature_request, or unknown. Provide relevant keywords, a confidence score (0-1), and a brief summary. Respond strictly in JSON format.` },
            { role: "user", content: `Classify the following query: "${params.query}"` },
          ],
          response_format: { type: "json_object" },
          temperature: 0.1,
        });

        const rawResult = response.choices[0].message?.content;
        if (!rawResult) {
          throw new Error("AI classification failed to return content.");
        }
        const parsedResult = JSON.parse(rawResult);
        const validatedClassification = ClassificationSchema.parse(parsedResult);
        return validatedClassification;
      }
    );

    io.logger.info("Query classified", { classification });

    let responseToCustomer: string | null = null;
    let humanProvidedResponse = false; // Track if a human provided the final response

    if (classification.type === "simple_faq" && classification.confidence > 0.7) {
      io.logger.info("Attempting to answer simple FAQ from knowledge base.");
      const kbAnswer = await io.runTask(
        "knowledge-base-lookup",
        {
          name: "Lookup Answer in Knowledge Base",
          icon: "database",
          params: { query: payload.query },
        },
        async (task, params) => {
          return await fetchKnowledgeBase(params.query);
        }
      );

      if (kbAnswer) {
        responseToCustomer = kbAnswer;
        io.logger.info("Found answer in KB.", { answer: kbAnswer });
      } else {
        io.logger.warn("Could not find answer in KB, will consider human escalation.");
      }
    }

    // ⚠️ What can go wrong: If no automated response is found,
    // or if the query is complex (e.g., classification.type !== "simple_faq"),
    // we need human intervention.
    if (!responseToCustomer) {
      io.logger.info("Escalating to human agent for review.");

      // Simulate sending a notification to a human agent dashboard/email.
      // This task would typically integrate with external communication systems.
      await io.runTask(
        "notify-human-agent",
        {
          name: "Notify Human Agent of Escalation",
          icon: "bell", // Use a bell icon for notifications
          params: {
            userId: payload.userId,
            query: payload.query,
            classification: classification.summary || classification.type,
            runId: ctx.run.id, // Pass the run ID so the human can reference it
          },
        },
        async (task, params) => {
          // In a real system, this would send an email, Slack message,
          // or create a ticket in a CRM like Zendesk or Salesforce.
          io.logger.warn(`Human Agent NOTIFIED: User ${params.userId} needs help with: ${params.query}. Run ID: ${params.runId}`);
          // Return a URL for the human to interact with this specific case.
          return `https://your-agent-dashboard.com/review/${ctx.run.id}`;
        }
      );

      // ⚡ Quick Note: io.wait is crucial for Human-in-the-Loop workflows.
      // It pauses the workflow indefinitely (up to the timeout) until an
      // external event is received, without consuming active compute resources.
      const humanInput = await io.wait(
        "wait-for-human-input",
        {
          name: "Wait for Human Agent Input",
          timeoutInSeconds: 60 * 60 * 24 * 7, // Wait for 7 days for human input
          // Define the schema for the event we are waiting for.
          // This event would typically be sent from your human agent dashboard
          // when they submit their review or response.
          // Example: client.sendEvent("human.input", { runId: ctx.run.id, response: "...", approved: true });
          schema: z.object({
            response: z.string().min(1, "Response cannot be empty."),
            approved: z.boolean().optional().default(true),
          }),
        }
      );

      io.logger.info("Human agent provided input.", { humanInput });
      if (humanInput.approved) {
        responseToCustomer = humanInput.response;
        humanProvidedResponse = true;
      } else {
        // Human agent disapproved, maybe they need more info or
        // it requires a different action. For this example, we'll
        // still use their response but log the disapproval.
        responseToCustomer = "The human agent reviewed your query. " + humanInput.response;
        io.logger.warn("Human agent disapproved the automated response, but provided an alternative.");
      }
    }

    // ⚡ Real-world insight: If after all steps, we still don't have a response,
    // it's a critical failure or needs direct human contact.
    if (!responseToCustomer) {
      io.logger.error("Failed to generate a response, even after human escalation.");
      // In a real system, you might create a high-priority ticket or directly call the user.
      throw new Error("Unable to resolve customer query automatically or with human input.");
    }

    // Finally, send the response to the customer.
    // ... (rest of the run function will go here)
  },
});

Explanation:

  • We introduce a humanProvidedResponse boolean to track whether the final response originated from a human agent.
  • The if (!responseToCustomer) block handles the escalation. This condition triggers if the AI and knowledge base couldn’t provide a satisfactory answer.
  • io.runTask("notify-human-agent", ...) simulates notifying a human agent. In a production system, this would trigger an email, a Slack message, or create a task in a dedicated agent dashboard. The task returns a URL for the human to access the specific case.
  • io.wait("wait-for-human-input", ...) is the core of the HITL. It pauses the workflow at this exact point.
    • The timeoutInSeconds ensures the workflow doesn’t wait forever, providing a safety net.
    • The schema for io.wait defines the expected structure of the external event that will resume this workflow. A human agent would typically interact with a UI that, upon submission, sends an event back to Trigger.dev (e.g., client.sendEvent("human.input", { runId: ctx.run.id, response: "..." })) matching this schema.
  • Once the human input event is received, the workflow resumes, and responseToCustomer is updated.
  • A final check ensures a response is always generated, or an explicit error is thrown for unresolvable queries, preventing silent failures.

To test the human escalation, send a query that won’t be in our mock knowledge base and is likely classified as complex_issue or unknown:

npx trigger.dev@v4-beta send customer.query '{"userId": "user-456", "query": "I need help configuring my new smart home device with the app, it keeps failing. Can you help me troubleshoot?"}'

The workflow will pause at the wait-for-human-input step. Go to your Trigger.dev dashboard, find the running job, and you’ll see it waiting. To resume it, you need to send an event. You can simulate this from your terminal:

npx trigger.dev@v4-beta send human.input '{"runId": "PASTE_YOUR_RUN_ID_HERE", "response": "Hello! I understand your smart home device setup is challenging. Please ensure your device is in pairing mode and your app is updated. If issues persist, try restarting your router. Let me know if that helps!", "approved": true}'

CRITICAL: Replace PASTE_YOUR_RUN_ID_HERE with the actual run.id from your Trigger.dev dashboard for the paused job. After sending this, the job will resume and complete, incorporating the human’s response.

Step 4: Sending the Response to the Customer

The final step in our workflow is to deliver the generated or human-approved response back to the customer. This could be via email, a messaging platform, or a webhook to your frontend application.

Complete the run function in src/jobs/customerSupportAgent.ts by adding the final io.runTask call after the last if (!responseToCustomer) block:

// src/jobs/customerSupportAgent.ts (continued)
// ... (previous code for imports, client, OpenAI, ClassificationSchema, fetchKnowledgeBase)

client.defineJob({
  id: "customer-support-agent",
  name: "AI Customer Support Agent",
  version: "1.0.0",
  enabled: true,
  trigger: client.defineEventTrigger({
    name: "customer.query",
    schema: z.object({
      userId: z.string(),
      query: z.string(),
      conversationId: z.string().optional(),
    }),
  }),
  run: async (payload, io, ctx) => {
    io.logger.info("Received customer query", payload);

    const classification = await io.runTask(
      "classify-query",
      {
        name: "Classify Customer Query with AI",
        icon: "openai",
        params: { query: payload.query },
      },
      async (task, params) => {
        const response = await openai.chat.completions.create({
          model: "gpt-4o",
          messages: [
            { role: "system", content: `You are a helpful assistant that classifies customer queries into one of these categories: simple_faq, complex_issue, billing_query, feature_request, or unknown. Provide relevant keywords, a confidence score (0-1), and a brief summary. Respond strictly in JSON format.` },
            { role: "user", content: `Classify the following query: "${params.query}"` },
          ],
          response_format: { type: "json_object" },
          temperature: 0.1,
        });

        const rawResult = response.choices[0].message?.content;
        if (!rawResult) {
          throw new Error("AI classification failed to return content.");
        }
        const parsedResult = JSON.parse(rawResult);
        const validatedClassification = ClassificationSchema.parse(parsedResult);
        return validatedClassification;
      }
    );

    io.logger.info("Query classified", { classification });

    let responseToCustomer: string | null = null;
    let humanProvidedResponse = false;

    if (classification.type === "simple_faq" && classification.confidence > 0.7) {
      io.logger.info("Attempting to answer simple FAQ from knowledge base.");
      const kbAnswer = await io.runTask(
        "knowledge-base-lookup",
        {
          name: "Lookup Answer in Knowledge Base",
          icon: "database",
          params: { query: payload.query },
        },
        async (task, params) => {
          return await fetchKnowledgeBase(params.query);
        }
      );

      if (kbAnswer) {
        responseToCustomer = kbAnswer;
        io.logger.info("Found answer in KB.", { answer: kbAnswer });
      } else {
        io.logger.warn("Could not find answer in KB, will consider human escalation.");
      }
    }

    if (!responseToCustomer) {
      io.logger.info("Escalating to human agent for review.");

      await io.runTask(
        "notify-human-agent",
        {
          name: "Notify Human Agent of Escalation",
          icon: "bell",
          params: {
            userId: payload.userId,
            query: payload.query,
            classification: classification.summary || classification.type,
            runId: ctx.run.id,
          },
        },
        async (task, params) => {
          io.logger.warn(`Human Agent NOTIFIED: User ${params.userId} needs help with: ${params.query}. Run ID: ${params.runId}`);
          return `https://your-agent-dashboard.com/review/${ctx.run.id}`;
        }
      );

      const humanInput = await io.wait(
        "wait-for-human-input",
        {
          name: "Wait for Human Agent Input",
          timeoutInSeconds: 60 * 60 * 24 * 7,
          schema: z.object({
            response: z.string().min(1, "Response cannot be empty."),
            approved: z.boolean().optional().default(true),
          }),
        }
      );

      io.logger.info("Human agent provided input.", { humanInput });
      if (humanInput.approved) {
        responseToCustomer = humanInput.response;
        humanProvidedResponse = true;
      } else {
        responseToCustomer = "The human agent reviewed your query. " + humanInput.response;
        io.logger.warn("Human agent disapproved the automated response, but provided an alternative.");
      }
    }

    if (!responseToCustomer) {
      io.logger.error("Failed to generate a response, even after human escalation.");
      throw new Error("Unable to resolve customer query automatically or with human input.");
    }

    // 🔥 Optimization / Pro tip: Use a dedicated messaging service or webhook here.
    // This task represents the final delivery of the response to the customer.
    await io.runTask(
      "send-response-to-customer",
      {
        name: "Send Response to Customer",
        icon: "send", // Use a send icon
        params: {
          userId: payload.userId,
          message: responseToCustomer,
          source: humanProvidedResponse ? "human" : "ai", // Indicate if human or AI generated
          conversationId: payload.conversationId,
        },
      },
      async (task, params) => {
        // In a real application, this would send an email (e.g., via SendGrid),
        // push a message to a chat UI (e.g., via WebSockets), or call a webhook
        // to update a CRM or a custom frontend.
        io.logger.info(`Sending response to user ${params.userId}: ${params.message}`);
        // Simulate a successful send operation
        return { success: true, messageId: `msg-${Date.now()}` };
      }
    );

    io.logger.info("Workflow completed successfully!");
    // Return a final status and the response for observability
    return { status: "resolved", finalResponse: responseToCustomer };
  },
});

Explanation:

  • The final io.runTask("send-response-to-customer", ...) simulates delivering the message back to the customer. This would likely involve integrating with a specific messaging API (e.g., Twilio for SMS, SendGrid for email, or a custom WebSocket service for real-time chat in a web application).
  • The source parameter helps track whether the response originated from the AI directly or was approved/modified by a human, which can be valuable for analytics and auditing.
  • The workflow concludes by logging its success and returning a final status and the response, making the outcome clear in the Trigger.dev dashboard.

Now, you have a complete AI-powered customer support agent workflow! Test it with various queries to observe the different paths: direct AI response from the knowledge base, or human escalation followed by a human-approved response.

Mini-Challenge: Contextual Conversations

Our current agent handles each customer query in isolation. However, real customer support conversations are contextual, building upon previous turns.

Challenge: Modify the customer-support-agent workflow to maintain a simple conversation history. When a new query comes in, if it’s part of an existing conversationId, retrieve the previous turn(s) and include them in the AI classification prompt to give the AI more context.

Hint:

  • You’ll need io.store.set to save the current query and response (and optionally the classification) at the end of a job run, associated with the conversationId.
  • You’ll need io.store.get to retrieve past conversation turns associated with a conversationId at the beginning of a new job run.
  • The payload already includes an optional conversationId.
  • Pass the retrieved history as part of the messages array to the OpenAI API when classifying the new query. Remember to limit the history to a reasonable number of turns (e.g., the last 3-5 turns) to stay within token limits and manage costs. Consider structuring the history as [{ role: "user", content: "..." }, { role: "assistant", content: "..." }].

What to observe/learn: This challenge will teach you how to use Trigger.dev’s durable storage (io.store) to maintain state across workflow runs, enabling long-running, stateful interactions like multi-turn conversations. It also reinforces the importance of prompt engineering for contextual AI.

Common Pitfalls & Troubleshooting

Building distributed AI workflows, especially those involving human interaction, can introduce new challenges. Here are some common pitfalls and how to troubleshoot them with Trigger.dev.

  1. State Management in Long-Running Workflows:

    • Pitfall: Forgetting to persist critical data (like conversation history, user preferences, or intermediate results) using Trigger.dev’s io.store or passing it explicitly between io.runTask calls. If your workflow involves io.wait or io.sleep, any in-memory state will be lost when the workflow pauses and resumes.
    • Troubleshooting: Always assume that anything not explicitly stored or passed will be lost across io.wait boundaries or between distinct job runs.
      • Use io.store.set and io.store.get for state that needs to persist across pauses or multiple workflow runs (e.g., conversation history associated with a conversationId).
      • For transient data within a single io.runTask or between synchronous steps within the same run function execution, local variables are perfectly fine.
  2. API Rate Limits with AI Services:

    • Pitfall: Hitting rate limits on external AI APIs (like OpenAI) during peak usage or when processing many concurrent requests, leading to 429 Too Many Requests errors. This can interrupt your workflow.
    • Troubleshooting: Trigger.dev’s io.runTask automatically handles retries with exponential backoff for transient errors, which is often sufficient for occasional rate limit issues. For very high throughput or sustained rate limiting, consider:
      • Implementing client-side rate limiting before sending events to Trigger.dev, if your system generates events very rapidly.
      • Using batch processing if the AI API supports it (e.g., sending multiple classification requests in one API call).
      • Distributing requests across multiple AI API keys or accounts if your provider and license allow it.
      • Optimizing AI prompts to reduce token usage and improve response times, which can indirectly help with rate limits.
  3. Debugging Asynchronous and Distributed Workflows:

    • Pitfall: It can be hard to trace the flow of execution, especially when io.wait pauses a workflow for an extended period, or when issues occur across multiple io.runTask calls and external services. Traditional console.log debugging is insufficient.
    • Troubleshooting:
      • Trigger.dev Dashboard: This is your primary and most powerful tool. Every io.runTask, io.wait, and io.logger call is visible on a timeline, providing a clear, step-by-step trace of execution, including inputs, outputs, and any errors.
      • io.logger: Use io.logger.info, warn, error liberally to understand the state of your workflow at critical junctures. These logs appear directly in the dashboard, correlated with the specific task and run.
      • ctx.run.id: Use the run.id (available in the ctx object) to correlate logs and events across different systems if you’re integrating with external monitoring tools or CRMs. Pass this ID to external services so you can easily link their logs back to a specific Trigger.dev workflow run.

Summary

Congratulations! You’ve successfully built a sophisticated AI-powered customer support agent using Trigger.dev, integrating AI classification, knowledge base lookup, and human-in-the-loop escalation.

Here are the key takeaways from this project:

  • AI Agent Orchestration: Trigger.dev provides a robust and observable platform for orchestrating complex AI agent workflows, seamlessly integrating AI inference with business logic and external services.
  • Human-in-the-Loop (HITL): You learned how to implement essential HITL patterns using io.wait, ensuring human oversight for critical decisions and ambiguous cases, building trust and improving accuracy.
  • Durable Execution: The agent leveraged Trigger.dev’s durable execution to maintain state and context across long-running, multi-step processes, including pauses for human input, without losing progress.
  • Robustness: Features like automatic retries (via io.runTask) for transient failures and clear observability in the Trigger.dev dashboard contribute to a highly resilient and reliable system.
  • Modular Design: By breaking down the agent’s logic into distinct io.runTask calls and conditional paths, we created a modular, easy-to-understand, and maintainable workflow.
  • Schema Validation: Using Zod for validating AI outputs is a critical best practice for building robust AI-driven applications, ensuring predictable data flow.

This project demonstrates how Trigger.dev empowers developers to build intelligent, reliable, and scalable automated systems that combine the power of AI with necessary human intervention. From here, you can further enhance this agent with more complex AI reasoning, integrations with real CRMs, and rich user interfaces for both customers and human agents.

References

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