Build an n8n AI Support Agent That Creates Tickets with Human Approval

The first version of this workflow is easy to build: a support email arrives, an AI node summarizes it, and n8n creates a ticket.

The second version is the one you actually need.

The customer email contains a quoted thread from three weeks ago. The AI reads the footer instead of the problem. The severity becomes “critical” because the customer used the word “urgent” in a signature. The workflow creates six duplicate tickets because the email provider retried the webhook. Then someone realizes the agent also created a ticket in the wrong project and tagged the on-call engineer because the model guessed a component name.

The problem is not that AI is bad at drafting support tickets. The problem is that a probabilistic system should not have direct write access to a system of record without a deterministic gate.

A production-ready n8n AI support agent should do three things:

  1. Draft a structured ticket from incoming support input.
  2. Ask a human to approve, reject, or edit the draft.
  3. Create the ticket only after approval, with idempotency and auditability.

That is the pattern this article builds.

TL;DR

  • Do not give the AI node direct access to Jira, Zendesk, Linear, GitHub Issues, or ServiceNow.
  • Use the LLM to produce a strict JSON ticket draft.
  • Validate the AI output like an untrusted webhook payload.
  • Store the draft in a database with a pending approval state.
  • Send a human-friendly approval request with a secure, expiring link or button.
  • Make the approval action idempotent.
  • Create the ticket only after approval, using a stable idempotency key or external reference.
  • Log the raw input, AI draft, approval decision, and final ticket ID.

📋 Table of Contents

The dangerous part is not the AI; it is the write access

Most AI automation failures are not caused by the model being “dumb.” They are caused by architecture.

If your workflow looks like this:

Incoming message → AI → Create ticket

then the model is effectively making production decisions. It decides when a ticket exists, what severity it has, what project it goes into, and who may be notified.

That is too much authority for a system that can confidently produce plausible but wrong output.

The safer architecture looks like this:

Incoming message
  → Normalize input
  → AI drafts structured ticket
  → Validate output
  → Store pending draft
  → Human approval
  → Create ticket idempotently
  → Audit trail

The AI is still useful. It reads messy support input, extracts intent, proposes severity, drafts a title, and writes a clean description. But it does not get to act alone.

In n8n terms, this means your AI node should not have a ticket-creation tool attached to it. If you are using n8n’s AI Agent or LangChain-style nodes, be especially careful here. Agents with tools are powerful, but for this use case you want a narrower design: the AI produces a draft, and ordinary workflow nodes handle the business process.

That distinction matters.

1. The safe shape: AI drafts, human approves, workflow writes

Scenario:
A customer emails support saying, “We cannot log in after the last update. This is urgent.” The AI creates a high-priority ticket. So far, so good. Then the same customer replies to their own email with “Ignore that, we found the issue.” The email provider retries the webhook. The workflow creates three tickets, one of them marked critical.

Why it matters:
Support systems are noisy. Messages contain quoted text, signatures, legal disclaimers, forwarded threads, duplicate submissions, and emotional language. The AI can help interpret that noise, but the workflow must remain deterministic around it.

Solution:
Split the system into three zones.

Zone 1: Ingestion

This is where the support request enters n8n. It might be:

  • An email webhook
  • A form submission
  • A chat message
  • A Slack message
  • A Zendesk trigger
  • A webhook from your app
  • A scheduled import from a mailbox

The ingestion layer should normalize the input and assign a stable external reference if possible.

Zone 2: Drafting

The AI reads the normalized input and returns a structured draft:

{
  "title": "Login failure after latest update",
  "description": "Customer reports inability to log in after the latest update.",
  "severity": "high",
  "product_area": "auth",
  "confidence": 0.82,
  "missing_information": [
    "Customer workspace ID",
    "Browser and OS details"
  ]
}

This zone should not create anything external.

Zone 3: Approval and execution

The draft is stored as a pending record. A human reviews it. If approved, a separate controlled step creates the ticket.

This separation gives you several benefits:

  • The AI cannot accidentally create tickets.
  • Humans can correct the draft before it enters the ticket system.
  • Duplicate messages can be detected before approval.
  • Approval decisions can be audited.
  • Ticket creation can be retried safely.
  • Low-confidence drafts can be routed to a human triage queue instead of becoming tickets.

🧠 The important part:
The AI should be treated as a drafting assistant, not as an operator. The workflow, not the model, owns state transitions.

2. Choose the approval model before building the prompt

Before writing a clever prompt, decide how human approval will work. This decision affects your database design, error handling, UX, and operational model.

There are three common patterns.

Approval modelBest forComplexityMain risk
n8n Wait nodeSimple internal approvals, short-lived workflowsLowExecution timeout, weak auditability, hard to edit draft
Stored draft plus approval webhookProduction systems, audit requirements, delayed approvalsMediumMore setup, requires storage
Slack or email interactive buttonsGood approver UXMedium to highButton/action handling, authentication, idempotency

The simple version: n8n Wait node

n8n’s Wait node can pause a workflow and resume it later, including via webhook. For a quick internal prototype, this is attractive.

The flow can be:

AI draft → Wait for approval webhook → Create ticket

You send an approval link to a human. When the human clicks it, the waiting execution resumes.

This is fine for demos and short-lived approvals.

But in production, I would be cautious about using the Wait node as the only source of truth. Approvals may take hours or days. Execution data retention, timeouts, retries, restarts, and operational debugging can become painful. Also, if the human wants to edit the draft before approving, the paused execution is not a great place to store that state.

The production version: stored draft

A better model is to store the draft in a database:

AI draft → Insert pending draft → Send approval request
Approval webhook → Update draft status → Create ticket

The draft table becomes the source of truth. The workflow execution becomes a worker that moves records between states.

This gives you:

  • Audit history
  • Expiration
  • Editing before approval
  • Duplicate prevention
  • Retry safety
  • Easier operational inspection

The UX layer: Slack, email, or internal dashboard

The approval request can be sent through Slack, email, Microsoft Teams, or an internal admin page. The transport matters less than the state model.

A good production design is:

  • Database draft as source of truth
  • Slack or email as notification layer
  • Secure approval endpoint or button as decision layer
  • n8n workflow as executor

💡 Practical note:
If you use email approval links, avoid making a simple GET request create the ticket. Email clients, link previews, and prefetchers can trigger unexpected requests. Use a review page with a confirmation button, or require a POST action.

3. Define the ticket contract before prompting

Scenario:
You ask the AI to “create a ticket payload.” It returns a priority of high-ish, a description that includes markdown tables, and a field called urgency_level that your ticket system does not understand.

Why it matters:
Ticket systems are structured. Jira has projects, issue types, priorities, labels, and custom fields. Zendesk has ticket forms, tags, and statuses. Linear has teams and labels. If the AI output is not constrained, you will spend all your time cleaning it up.

Solution:
Define a ticket draft schema before you write the prompt.

A practical schema might look like this:

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "title",
    "description",
    "severity",
    "product_area",
    "confidence",
    "recommended_action"
  ],
  "properties": {
    "title": {
      "type": "string",
      "maxLength": 120
    },
    "description": {
      "type": "string",
      "maxLength": 4000
    },
    "severity": {
      "type": "string",
      "enum": [
        "low",
        "medium",
        "high",
        "critical"
      ]
    },
    "product_area": {
      "type": "string",
      "enum": [
        "auth",
        "billing",
        "api",
        "ui",
        "integrations",
        "unknown"
      ]
    },
    "customer_impact": {
      "type": "string",
      "maxLength": 1000
    },
    "recommended_action": {
      "type": "string",
      "enum": [
        "create_ticket",
        "need_more_info",
        "reply_and_close",
        "escalate_human",
        "discard"
      ]
    },
    "confidence": {
      "type": "number",
      "minimum": 0,
      "maximum": 1
    },
    "missing_information": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "suggested_labels": {
      "type": "array",
      "items": {
        "type": "string",
        "maxLength": 50
      }
    },
    "do_not_auto_create": {
      "type": "boolean"
    }
  }
}

This schema does a few important things.

First, it forces the AI to choose from a small set of severity values. That is better than letting it invent labels like urgent, blocker, or super high.

Second, it asks for a recommended_action. Sometimes the correct response is not “create ticket.” It may be “ask the customer for more information” or “escalate to a human immediately.”

Third, it asks for a confidence score. The confidence score alone is not perfect, but it gives you a useful routing signal. Low-confidence drafts can go to a human triage queue instead of the normal approval queue.

Fourth, it includes do_not_auto_create. This is useful for sensitive cases: legal threats, security incidents, payment disputes, abusive messages, or anything that should never be processed automatically.

If your model provider supports structured output or JSON schema mode, use it. If you are using n8n’s AI nodes, prefer the structured output mechanisms available in your version. If not, ask for JSON and validate aggressively.

⚠️ Gotcha:
Do not let the AI define your ticket system’s fields dynamically. The schema should be owned by your workflow, not inferred by the model.

4. Normalize the incoming support message first

Scenario:
A customer replies to an old email thread. The latest message says, “Actually, this is fixed.” The AI reads the entire quoted history and creates a new urgent ticket for a problem that is already resolved.

Why it matters:
Raw support input is messy. It includes HTML, quoted replies, signatures, disclaimers, tracking pixels, forwarded messages, and sometimes accidental CCs. If you hand all of that directly to the model, the model may draft from the wrong part of the text.

Solution:
Add a normalization step before the AI call.

At minimum, extract:

  • Source channel
  • Sender identity
  • Timestamp
  • External message ID
  • Subject
  • Visible customer text
  • Attachment metadata
  • Conversation or thread ID

In n8n, you can use a Code node to normalize the payload.

Example:

const items = $input.all();
const output = [];

for (const item of items) {
  const raw = item.json;

  const html = raw.body?.html ?? '';
  const plainText = raw.body?.text ?? '';

  // Very basic cleanup. Use a proper HTML parser or email parser in production.
  const cleanedFromHtml = html
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;/gi, ' ')
    .replace(/&amp;/gi, '&')
    .replace(/&lt;/gi, '<')
    .replace(/&gt;/gi, '>')
    .replace(/\s+/g, ' ')
    .trim();

  const customerText = plainText.trim() || cleanedFromHtml;

  output.push({
    json: {
      source: raw.source ?? 'email',
      externalMessageId: raw.messageId ?? raw.id ?? null,
      fromEmail: raw.from?.email ?? null,
      fromName: raw.from?.name ?? null,
      subject: raw.subject ?? '',
      receivedAt: raw.date ?? null,
      customerText,
      rawPayload: raw
    }
  });
}

return output;

This example is intentionally conservative. It keeps the raw payload in rawPayload so you can audit it later, while giving the AI a cleaner customerText field.

In a real support system, you may want more advanced handling:

  • Strip quoted reply blocks
  • Detect signatures
  • Remove legal disclaimers
  • Detect out-of-office replies
  • Extract order IDs or account IDs
  • Detect attachments without downloading them
  • Redact secrets before the AI call

If you are receiving email, a dedicated email parsing library or service is often better than regex alone. HTML email is brutal.

Also, use the external message ID for idempotency. Email providers, chat platforms, and webhook senders can retry. You do not want the same incoming message to become multiple drafts.

🔍 Why this matters:
The quality of the AI draft is limited by the quality of the input. A good prompt cannot fully compensate for a bad ingestion layer.

5. Prompt the model to draft, not to act

Scenario:
The prompt says, “You are a support agent. Create a ticket for this issue.” The model responds with a fake ticket ID, invents a customer account number, and claims it has already opened the ticket.

Why it matters:
Language models respond strongly to action-oriented framing. If you ask the model to “create,” “submit,” or “resolve,” it may hallucinate completion. For this workflow, the model should only produce a draft.

Solution:
Use a system prompt that makes the role explicit.

Example:

You are a support triage assistant.

Your job is to read an incoming support message and produce a structured ticket draft.

Rules:
1. Return only valid JSON.
2. Do not include markdown fences.
3. Do not invent facts that are not present in the input.
4. If information is missing, add it to missing_information.
5. If you are unsure about severity, use "medium" and explain what is missing.
6. Do not include passwords, API keys, tokens, full credit card numbers, or other secrets.
7. If the message appears to be spam, sales, or not a support request, set recommended_action to "discard".
8. If the message contains legal threats, safety issues, or sensitive regulatory content, set do_not_auto_create to true.
9. Keep the title short and factual.
10. Write the description in a neutral support tone.

Then build the user prompt from normalized data:

const input = $input.first().json;

const systemPrompt = `You are a support triage assistant.

Return only valid JSON matching the required schema.

Do not invent facts. Do not include secrets. If unsure, use "medium" severity and list missing information.`;

const userPrompt = `Incoming support message:

Source: ${input.source}
Subject: ${input.subject}
From: ${input.fromName ?? 'Unknown'} <${input.fromEmail ?? 'unknown'}>
Received at: ${input.receivedAt ?? 'unknown'}

Message:
${input.customerText}

Return the ticket draft as JSON.`;

return [
  {
    json: {
      systemPrompt,
      userPrompt,
      normalizedInput: input
    }
  }
];

In n8n, you can pass these into your AI node, OpenAI node, or HTTP Request node. The exact node is less important than the contract.

Use a low temperature for this task. Ticket triage is not a creative writing exercise. You want consistency, not stylistic variety.

Also, be careful with memory. For a support ticket agent, request-scoped context is usually enough. You generally do not need conversational memory that carries state across unrelated support messages. If you need customer history, retrieve it explicitly from your system of record and include it as controlled context.

Do not give this drafting step any tools that can create tickets, send replies, or modify customer data.

🚨 Production warning:
If the drafting agent has a ticket-creation tool, you have reintroduced the original problem. The model may call it when you did not expect it to. Keep drafting and execution separated.

6. Treat AI output like an untrusted webhook

Scenario:
The model returns valid JSON most of the time. Then one day it wraps the JSON in markdown code fences, returns severity: "URGENT", and puts the description in a nested object your workflow does not expect.

Why it matters:
AI output is not reliable enough to pass directly into a ticketing API. Even with structured output enabled, you should validate it.

Solution:
Add a validation and normalization Code node after the AI call.

Example:

const items = $input.all();
const output = [];

const allowedSeverity = new Set(['low', 'medium', 'high', 'critical']);
const allowedAction = new Set([
  'create_ticket',
  'need_more_info',
  'reply_and_close',
  'escalate_human',
  'discard'
]);

function stripCodeFences(value) {
  return String(value)
    .trim()
    .replace(/^```json\s*/i, '')
    .replace(/^```\s*/i, '')
    .replace(/```$/i, '')
    .trim();
}

for (const item of items) {
  const raw = item.json.llmOutput ?? item.json.text ?? item.json.response;

  let draft = null;
  const errors = [];

  try {
    draft = JSON.parse(stripCodeFences(raw));
  } catch (error) {
    errors.push('invalid_json');
  }

  if (!draft || typeof draft !== 'object') {
    output.push({
      json: {
        valid: false,
        errors: ['missing_draft_object'],
        rawOutput: raw
      }
    });
    continue;
  }

  const title = typeof draft.title === 'string' ? draft.title.trim() : '';
  const description = typeof draft.description === 'string'
    ? draft.description.trim()
    : '';

  if (title.length < 8 || title.length > 120) {
    errors.push('invalid_title');
  }

  if (description.length < 10 || description.length > 4000) {
    errors.push('invalid_description');
  }

  const severity = allowedSeverity.has(draft.severity)
    ? draft.severity
    : 'medium';

  const recommendedAction = allowedAction.has(draft.recommended_action)
    ? draft.recommended_action
    : 'escalate_human';

  const confidenceRaw = Number(draft.confidence);
  const confidence = Number.isFinite(confidenceRaw)
    ? Math.max(0, Math.min(1, confidenceRaw))
    : 0;

  const missingInformation = Array.isArray(draft.missing_information)
    ? draft.missing_information.filter(x => typeof x === 'string').slice(0, 10)
    : [];

  const suggestedLabels = Array.isArray(draft.suggested_labels)
    ? draft.suggested_labels
        .filter(x => typeof x === 'string')
        .map(x => x.trim().toLowerCase().replace(/\s+/g, '-'))
        .slice(0, 5)
    : [];

  const cleanDraft = {
    title,
    description,
    severity,
    product_area: typeof draft.product_area === 'string'
      ? draft.product_area
      : 'unknown',
    customer_impact: typeof draft.customer_impact === 'string'
      ? draft.customer_impact.trim()
      : '',
    recommended_action: recommendedAction,
    confidence,
    missing_information: missingInformation,
    suggested_labels: suggestedLabels,
    do_not_auto_create: Boolean(draft.do_not_auto_create)
  };

  output.push({
    json: {
      valid: errors.length === 0,
      errors,
      draft: cleanDraft,
      normalizedInput: item.json.normalizedInput ?? null,
      rawOutput: raw
    }
  });
}

return output;

This node does several important things.

It strips markdown fences because models sometimes add them even when asked not to.

It normalizes invalid severity values to medium. This is safer than failing the whole workflow for a minor model mistake.

It forces unknown recommended actions into escalate_human. If the model says something unexpected, a human should look at it.

It clamps confidence between zero and one.

It keeps the raw output for debugging.

After this node, use routing logic:

  • If valid is false, send to human triage.
  • If recommended_action is discard, log and stop, or send to a review queue depending on your risk tolerance.
  • If recommended_action is need_more_info, trigger a request for more information instead of ticket creation.
  • If do_not_auto_create is true, route to a human immediately.
  • If confidence is below your threshold, route to manual review.
  • If everything passes, continue to pending draft storage.

Do not trust the model’s confidence score completely. It is a heuristic. But it is still useful when combined with validation and business rules.

7. Store a pending draft before asking a human

Scenario:
The approval email is sent at 6:00 PM on Friday. The on-call engineer opens it on Monday morning. If your workflow was waiting in memory or relying on a short-lived execution, the approval may no longer work.

Why it matters:
Human approval is not instantaneous. Support teams work across time zones. People go offline. Requests need editing. Managers ask why a ticket was created. You need durable state.

Solution:
Store the AI ticket draft in a database before requesting approval.

A practical PostgreSQL table might look like this:

CREATE TABLE support_ticket_drafts (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours',

  status text NOT NULL DEFAULT 'pending_approval' CHECK (
    status IN (
      'pending_approval',
      'approved',
      'rejected',
      'expired',
      'created',
      'failed'
    )
  ),

  source text NOT NULL,
  external_message_id text,
  idempotency_key text UNIQUE,

  request_payload jsonb NOT NULL,
  ai_draft jsonb NOT NULL,
  human_edited_draft jsonb,

  approval_token uuid NOT NULL DEFAULT gen_random_uuid(),
  approved_by text,
  approved_at timestamptz,
  rejection_reason text,

  ticket_system text,
  ticket_id text,
  ticket_url text,

  error text
);

CREATE INDEX idx_support_ticket_drafts_status
ON support_ticket_drafts (status);

CREATE INDEX idx_support_ticket_drafts_external_message
ON support_ticket_drafts (source, external_message_id);

The important fields are:

  • status: the workflow state
  • idempotency_key: prevents duplicate drafts from duplicate inbound events
  • request_payload: the normalized support request
  • ai_draft: the AI’s proposed ticket
  • human_edited_draft: optional edits made during approval
  • approval_token: secure token for the approval link or action
  • ticket_id: final ticket reference after creation

In n8n, insert the draft with a Postgres node.

Example query:

INSERT INTO support_ticket_drafts (
  source,
  external_message_id,
  idempotency_key,
  request_payload,
  ai_draft,
  status
)
VALUES (
  $1,
  $2,
  $3,
  $4,
  $5,
  'pending_approval'
)
ON CONFLICT (idempotency_key)
DO UPDATE SET
  updated_at = support_ticket_drafts.updated_at
RETURNING
  id,
  status,
  approval_token,
  ai_draft;

The idempotency_key can be built from the source and external message ID:

const input = $input.first().json.normalizedInput;

const idempotencyKey = [
  input.source ?? 'unknown',
  input.externalMessageId ?? 'missing-id'
].join(':');

return [
  {
    json: {
      idempotencyKey
    }
  }
];

If the same message arrives twice, the second execution should not create a second draft.

💡 Practical note:
Store the raw input and AI draft separately. When someone asks, “Why did the agent suggest this?” you want to see exactly what the model saw and exactly what it produced.

8. Build an approval request that humans will actually use

Scenario:
The approver receives a Slack message containing 3,000 characters of raw AI JSON. They skim it, click approve, and later discover the ticket was assigned to the billing team for a problem that was actually a login issue.

Why it matters:
Human approval is only useful if the human can make a good decision quickly. If the approval request is too noisy, people will rubber-stamp it. If it is too vague, they cannot catch mistakes.

Solution:
Send a compact summary with clear risk signals.

A good approval message should include:

  • Customer identifier
  • Draft title
  • Severity
  • Product area
  • Confidence
  • Missing information
  • Recommended action
  • Short description preview
  • Link or button to review/approve
  • Expiration warning

Example Slack-style message:

AI support ticket draft needs approval

Customer: jane@example.com
Title: Login failure after latest update
Severity: high
Product area: auth
Confidence: 0.82
Recommended action: create_ticket

Missing information:
- Workspace ID
- Browser and OS

Description preview:
Customer reports that users cannot log in after the latest update.
The issue started around 14:30 UTC and affects multiple users.

Review draft: https://approvals.example.com/support-drafts/123

If you are using Slack interactive buttons, the approval action should call your approval webhook with the draft ID and decision. If you are using email, the link should open a review page rather than directly performing a state-changing GET.

In n8n expressions, an approval URL might look like this:

{{ $env.APPROVAL_BASE_URL }}/support-drafts/{{ $json.id }}?token={{ $json.approval_token }}

Do not log the approval token. Treat it like a one-time secret.

Also think about who can approve.

For low-risk tickets, a support lead may be enough.

For high-severity or billing-related tickets, you may want a second reviewer or a restricted approver list.

For do_not_auto_create drafts, approval should not mean “create ticket.” It should mean “hand this to a human specialist.”

If humans should be able to edit the draft before approval, store the edited version in human_edited_draft. When creating the ticket, prefer the human-edited draft over the AI draft.

UPDATE support_ticket_drafts
SET
  human_edited_draft = $2,
  updated_at = now()
WHERE
  id = $1
  AND status = 'pending_approval';

This gives you an audit trail showing what the AI suggested and what the human changed.

⚠️ Gotcha:
If the approval message contains sensitive customer data, make sure the channel is appropriate. A draft that includes account details may be fine in a private support tool but not in a broad Slack channel.

9. Make the approval decision idempotent

Scenario:
The approver clicks the approve button twice because the first click appeared to hang. The approval webhook is retried. Your workflow creates two tickets.

Why it matters:
Approval endpoints are just like payment or webhook endpoints: they can be duplicated. Network retries, impatient users, browser preloading, and Slack button double-clicks all happen.

Solution:
Make the approval transition conditional on the current status.

Do not do this:

UPDATE support_ticket_drafts
SET status = 'approved'
WHERE id = $1;

Do this instead:

UPDATE support_ticket_drafts
SET
  status = 'approved',
  approved_by = $2,
  approved_at = now(),
  updated_at = now()
WHERE
  id = $1
  AND approval_token = $3
  AND status = 'pending_approval'
  AND expires_at > now()
RETURNING *;

If the update returns zero rows, the draft was already approved, rejected, expired, or invalid. In that case, stop the workflow and return a safe response.

In an n8n approval webhook workflow, the shape is:

Webhook receives approval request
  → Validate ID and token
  → Update draft status conditionally
  → If no row updated, respond "already processed"
  → If approved, continue to ticket creation
  → If rejected, store reason and stop

For rejection:

UPDATE support_ticket_drafts
SET
  status = 'rejected',
  approved_by = $2,
  approved_at = now(),
  rejection_reason = $4,
  updated_at = now()
WHERE
  id = $1
  AND approval_token = $3
  AND status = 'pending_approval'
  AND expires_at > now()
RETURNING *;

If your approval action comes from Slack, the approved_by value can be the Slack user ID. If it comes from an internal dashboard, use the authenticated user identity. If it comes from an email magic link, you may only know that the token holder approved it, which is weaker. For sensitive workflows, require authentication in addition to the token.

Also handle expiration.

A draft that sits unapproved for 24 hours may no longer be safe to create automatically. The customer may have replied again. The issue may have been resolved. The AI draft may now be stale.

For expired drafts:

UPDATE support_ticket_drafts
SET
  status = 'expired',
  updated_at = now()
WHERE
  status = 'pending_approval'
  AND expires_at < now();

You can run this with a scheduled n8n workflow.

🧠 The important part:
Approval is a state transition, not just a click. The database should enforce that transition safely.

10. Create the ticket without duplicating it

Scenario:
The approval workflow calls Jira. Jira responds slowly. n8n retries. The ticket was actually created the first time, but the workflow did not receive the response. Now the retry creates a duplicate.

Why it matters:
Ticket creation is an external side effect. Once it happens, you cannot always undo it cleanly. Duplicate tickets annoy support teams, pollute reporting, and can trigger duplicate notifications.

Solution:
Make ticket creation idempotent or at least duplicate-resistant.

There are three useful techniques.

Technique 1: Use an idempotency key if the API supports it

Some APIs accept an idempotency key. If your ticket system or internal service supports it, send the draft ID as the key.

Example HTTP request:

POST /tickets
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: support-draft-8f2b2f6e-9b1c-4f1a-9b7a-1f5a6c9e3d10

{
  "title": "Login failure after latest update",
  "description": "Customer reports inability to log in after the latest update.",
  "severity": "high",
  "source": "email",
  "external_reference": "support-draft-8f2b2f6e-9b1c-4f1a-9b7a-1f5a6c9e3d10"
}

If the API supports idempotency properly, the retry returns the original ticket instead of creating a new one.

Technique 2: Store the draft ID in the ticket

If the ticket system does not support idempotency keys, store your draft ID somewhere searchable in the ticket.

Options include:

  • A custom field
  • A label
  • A tag
  • An external reference field
  • The ticket description, if no better field exists

For Jira, a label can work:

const item = $input.first().json;
const draft = item.human_edited_draft ?? item.ai_draft;

const body = {
  fields: {
    project: {
      key: 'SUPPORT'
    },
    summary: draft.title,
    issuetype: {
      name: 'Task'
    },
    labels: [
      `ai_draft_${item.id.replace(/-/g, '')}`
    ],
    description: {
      type: 'doc',
      version: 1,
      content: [
        {
          type: 'paragraph',
          content: [
            {
              type: 'text',
              text: draft.description
            }
          ]
        }
      ]
    }
  }
};

return [
  {
    json: {
      jiraBody: body
    }
  }
];

Before creating the ticket, search for that label or external reference. If it already exists, do not create another one. Update your draft record with the existing ticket ID instead.

The exact search API depends on the ticket system, but the pattern is the same:

Before create:
  Search for existing ticket by draft reference

If found:
  Use existing ticket

If not found:
  Create ticket
  Store ticket ID

Technique 3: Update your database immediately after creation

As soon as the ticket creation succeeds, store the ticket ID:

UPDATE support_ticket_drafts
SET
  status = 'created',
  ticket_system = 'jira',
  ticket_id = $2,
  ticket_url = $3,
  updated_at = now()
WHERE
  id = $1
  AND status = 'approved';

If the ticket creation call fails or times out, mark the draft as failed or leave it in a retryable state depending on your error handling strategy. Then retry carefully, using the duplicate check first.

A robust n8n execution flow looks like this:

Approved draft
  → Check whether ticket already exists
  → If yes, update draft and stop
  → If no, create ticket
  → Store ticket ID
  → Notify support channel or requester

Do not create the ticket and then rely on memory or execution state to remember that it happened. The database record should be the source of truth.

🔍 Why this matters:
Human approval prevents unwanted tickets. Idempotent execution prevents duplicate tickets. You need both.

Handling edits, low confidence, and sensitive cases

There are a few edge cases that deserve special attention.

Human-edited drafts

If a human edits the draft before approving, use the edited version when creating the ticket.

const draftRecord = $input.first().json;

const finalDraft = draftRecord.human_edited_draft ?? draftRecord.ai_draft;

return [
  {
    json: {
      finalDraft
    }
  }
];

This sounds obvious, but many workflows accidentally create the ticket from the original AI draft even after a human made corrections.

Low-confidence drafts

For low-confidence drafts, avoid sending a normal approve/reject request. Instead, route them to a human triage queue.

The message should say:

Low-confidence AI triage result. Human review required before ticket creation.

The human can then decide whether to:

  • Create a ticket manually
  • Ask the customer for more information
  • Merge the request into an existing ticket
  • Discard the request

Do not make low-confidence approval too easy. If the AI is unsure, the human interface should slow the process down slightly, not speed it up blindly.

Sensitive cases

If the AI flags do_not_auto_create, do not create a ticket through the normal automated path.

Examples include:

  • Legal threats
  • Harassment or abuse reports
  • Security incidents
  • Payment disputes
  • Regulatory requests
  • Messages containing sensitive personal data
  • Requests involving account deletion or data access

For these, create a restricted human task instead of a normal ticket draft. The goal is to reduce automation risk, not to maximize automation volume.

Observability and audit trail

A support agent that creates tickets with human approval still needs strong observability.

At minimum, log:

  • Incoming message ID
  • Source channel
  • Timestamp
  • Normalized input hash
  • AI model or prompt version
  • AI draft
  • Validation errors
  • Confidence score
  • Draft ID
  • Approval request timestamp
  • Approver identity
  • Approval decision
  • Human edits
  • Ticket creation request
  • Ticket ID
  • Failure reason, if any

If you are using Postgres, much of this is already in the draft table. You may also want a separate audit table for state changes.

Example:

CREATE TABLE support_ticket_draft_events (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  draft_id uuid NOT NULL REFERENCES support_ticket_drafts(id),
  event_type text NOT NULL,
  actor text,
  payload jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

Then insert events such as:

  • draft_created
  • validation_failed
  • approval_requested
  • approval_approved
  • approval_rejected
  • draft_expired
  • ticket_created
  • ticket_creation_failed

This becomes extremely useful when someone asks, “Why did this ticket exist?” or “Why did this message not become a ticket?”

Also monitor operational failures:

  • AI calls returning invalid JSON
  • Validation failure rate
  • Approval timeout rate
  • Ticket creation failure rate
  • Duplicate draft rate
  • Expired draft rate
  • Low-confidence rate

If the invalid JSON rate suddenly increases, your model or prompt may have changed. If the approval timeout rate is high, your approval UX may be poor. If duplicate drafts appear, your idempotency key may be weak.

Security considerations

This workflow handles support messages, which often contain personal or sensitive information. Treat it accordingly.

Do not put secrets in prompts

If a customer accidentally sends an API key, password, or token, you do not want that secret forwarded to a model provider, logged in plaintext, or stored unnecessarily.

Add best-effort redaction before the AI call:

  • API key patterns
  • Bearer tokens
  • Password-like query strings
  • Credit card numbers
  • National ID patterns where relevant
  • Base64 blobs that look like credentials

Regex redaction is not perfect, but it is better than nothing.

Protect approval tokens

Approval tokens should be:

  • Random
  • Expire after a reasonable period
  • Single-use where possible
  • Stored hashed if you do not need the raw token
  • Excluded from logs
  • Sent only over HTTPS

If approval links are emailed, consider requiring an additional login step before the action is accepted.

Restrict approval authority

Not every approval should be equal.

You can require stronger approval for:

  • Critical severity
  • Billing changes
  • Customer data modifications
  • Security-related tickets
  • Drafts with low confidence
  • Drafts where the AI changed its recommendation after validation

In n8n, this can be implemented by routing different draft types to different approval channels or by requiring different approver groups.

Be careful with PII in Slack or email

If your approval message includes customer details, make sure the destination is appropriate. A private support channel may be fine. A public engineering channel may not be.

What I would ship

If I were building this in a real support environment, I would not start with a fully autonomous agent.

I would ship this version:

  1. Inbound webhook or email trigger.
  2. Normalization node.
  3. Structured AI drafting node with strict JSON output.
  4. Validation node.
  5. Postgres draft table.
  6. Slack or email approval request.
  7. Approval webhook with conditional database update.
  8. Ticket creation with duplicate check.
  9. Audit events for every state transition.
  10. Hard rules that block automatic ticket creation for sensitive cases.

For the first version, I would also set a relatively high confidence threshold. It is better to send too many drafts to human triage early than to create bad tickets automatically.

Once the system has been running for a while, use the audit data to tune the threshold. Look at:

  • How often humans reject AI drafts
  • What fields humans edit most often
  • Which categories have low confidence
  • Which message types produce duplicates
  • Which severity guesses are wrong

That data tells you where to improve the prompt, the schema, or the routing rules.

Production checklist

Before turning this workflow on for real support traffic, verify the following.

Architecture

  • The AI node cannot create tickets directly.
  • Drafting and execution are separate workflow stages.
  • Human approval is required before ticket creation.
  • Sensitive cases bypass automatic ticket creation.

Input handling

  • Incoming messages are normalized.
  • External message IDs are captured.
  • Duplicate inbound events do not create duplicate drafts.
  • Raw input is retained for audit.

AI output

  • The model must return a strict schema.
  • Output is validated after the AI call.
  • Invalid JSON routes to human review.
  • Low-confidence drafts route to manual triage.
  • Secrets and obvious PII are redacted where possible.

Approval flow

  • Drafts are stored in a durable database.
  • Approval tokens are random and expire.
  • Approval actions are idempotent.
  • Approver identity is recorded.
  • Humans can edit drafts before approval.
  • Approval links do not perform destructive GET actions.

Ticket creation

  • Ticket creation happens only after approved status.
  • A stable reference or idempotency key is used.
  • Existing tickets are checked before creation.
  • Ticket IDs are written back to the draft record.
  • Failures are visible and retryable.

Observability

  • Every draft state change is logged.
  • Validation failures are monitored.
  • Approval timeouts are monitored.
  • Duplicate drafts are monitored.
  • Ticket creation failures are alerted.

The goal is not to prevent AI from being useful. It is to give the AI a clearly bounded job: read the request, draft the ticket, and hand the decision to a human. Once that boundary exists, the workflow becomes much easier to trust, debug, and improve.

Post a Comment

Previous Post Next Post