Build an AI Support Agent with n8n, Laravel, and RAG That Knows When to Ask for Help

The most dangerous AI support agent is not the one that says “I don’t know.”

It is the one that sounds confident, quotes the wrong policy, and tries to solve an account-specific billing exception with a generic help-center article.

A useful support agent needs more than retrieval-augmented generation. It needs boundaries. It needs to know when it has enough context, when it is guessing, and when the safest action is to stop and bring in a human.

That is the part most teams get wrong first.

This article walks through building a practical AI support agent with n8n, Laravel, and RAG, where the agent can answer support questions from your documentation but also knows when to escalate. The architecture assumes you are building something for production, not a demo: authenticated users, real conversations, queues, webhooks, policy rules, human handoff, and enough observability to know when the system is misbehaving.

TL;DR

  • Use Laravel as the system of record for conversations, users, permissions, and handoffs.
  • Use n8n as the orchestration layer for retrieval, LLM calls, tool execution, and routing.
  • Build RAG so it can fail visibly: metadata, retrieval scores, source freshness, and citation quality matter.
  • Do not let the LLM decide escalation purely by “feeling confident.” Encode explicit handoff policy.
  • Give the agent a real request_human_handoff tool, but validate it server-side.
  • Pass humans a structured handoff packet, not a raw chat transcript.
  • Test escalation like any other production feature.

📋 Table of Contents

1. The dangerous agent is the one that never asks for help

Scenario:
A customer asks, “I was charged twice for the Pro plan, but my invoice shows a discount that support promised last month.” A naive RAG agent retrieves your pricing FAQ, finds a chunk about Pro plan billing, and replies with a polished explanation of monthly billing cycles.

The answer is grammatically perfect. It is also useless.

This is the core problem with support automation. Support questions are not evenly distributed between “easy documentation lookup” and “needs human judgment.” The easy ones are obvious: password reset steps, API authentication examples, cancellation instructions. The hard ones are messy: exceptions, refunds, account state, legal language, angry customers, conflicting policies, and edge cases your docs never anticipated.

Why it matters:
RAG helps the model ground its answer in your content, but retrieval does not give the model judgment. It does not automatically know that an account-specific billing anomaly should not be answered from a generic pricing page. It does not know that a customer asking for a policy exception is no longer in the “self-service” lane.

Solution:
Treat “ask for help” as a first-class product capability.

An AI support agent should be able to do more than answer. It should be able to:

  1. Answer directly when the question is documentation-friendly.
  2. Ask a clarifying question when the intent is unclear.
  3. Collect missing information when the request is actionable but incomplete.
  4. Escalate to a human when the request is sensitive, ambiguous, account-specific, or outside policy.
  5. Stop acting when a human has taken over.

That last one is especially important. Nothing erodes trust faster than an agent continuing to chirp after a support engineer has already entered the conversation.

🧠 The important part: escalation is not failure. It is routing.

If you design the system so that handoff is a normal, observable, testable state transition, your agent becomes safer and more useful. If you treat escalation as an afterthought, you will eventually ship a bot that confidently makes promises it cannot keep.

2. Laravel should own the conversation, n8n should own the orchestration

A common mistake is to make the automation tool the source of truth. n8n is excellent at orchestration: calling models, retrieving documents, branching workflows, transforming payloads, and connecting systems. But your application’s support conversations are deeply tied to users, permissions, subscriptions, tickets, audit logs, and business rules.

That belongs in your application.

A production-friendly split looks like this:

ResponsibilityLaraveln8n
User authentication and authorization✅❌
Conversation persistence✅❌
Message history✅❌
Customer/account context✅❌
Handoff records✅❌
LLM orchestration❌✅
Vector retrieval⚠️ optional✅
Tool execution routing⚠️ limited✅
Prompt assembly⚠️ possible✅
Retry/branching logic⚠️ possible✅

The practical architecture is:

  1. The authenticated user sends a message through your Laravel app.
  2. Laravel stores the message and conversation state.
  3. Laravel dispatches a queued job to call an n8n webhook.
  4. n8n retrieves relevant documents, calls the LLM, evaluates handoff rules, and calls back into Laravel.
  5. Laravel stores the assistant reply or creates a human handoff.
  6. If a human takes over, Laravel marks the conversation as human-owned and the agent stops acting.

This keeps secrets on the server, gives you an audit trail, and prevents the frontend from talking directly to your automation layer.

Here is a realistic Laravel endpoint for receiving a customer message:

// routes/api.php

Route::middleware('auth:sanctum')->group(function () {
    Route::post(
        '/conversations/{conversation}/messages',
        StoreSupportMessage::class
    );
});
// app/Http/Controllers/Api/StoreSupportMessage.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Jobs\ForwardSupportMessageToN8n;
use App\Models\Conversation;
use Illuminate\Http\Request;

class StoreSupportMessage extends Controller
{
    public function __invoke(Conversation $conversation, Request $request)
    {
        $data = $request->validate([
            'body' => ['required', 'string', 'max:8000'],
            'client_message_id' => ['required', 'uuid'],
        ]);

        $message = $conversation->messages()->create([
            'role' => 'customer',
            'body' => $data['body'],
            'client_message_id' => $data['client_message_id'],
            'status' => 'queued_for_agent',
        ]);

        ForwardSupportMessageToN8n::dispatch($message);

        return response()->json([
            'message_id' => $message->id,
            'status' => 'queued',
        ], 202);
    }
}

The queued job sends a signed payload to n8n:

// app/Jobs/ForwardSupportMessageToN8n.php

namespace App\Jobs;

use App\Models\Message;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

class ForwardSupportMessageToN8n implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(public Message $message)
    {
    }

    public function handle(): void
    {
        $conversation = $this->message->conversation;

        if ($conversation->status === 'human_owned') {
            return;
        }

        $payload = [
            'conversation_id' => $conversation->id,
            'message_id' => $this->message->id,
            'customer_message' => $this->message->body,
            'conversation_status' => $conversation->status,
            'customer' => [
                'id' => $conversation->user_id,
                'plan' => $conversation->user->subscription_plan,
                'account_status' => $conversation->user->account_status,
            ],
            'recent_messages' => $conversation
                ->messages()
                ->latest()
                ->take(8)
                ->get()
                ->map(fn ($message) => [
                    'role' => $message->role,
                    'body' => $message->body,
                    'created_at' => $message->created_at->toIso8601String(),
                ])
                ->reverse()
                ->values(),
            'result_url' => route('n8n.replies.store'),
            'handoff_url' => route('n8n.handoffs.store'),
        ];

        $signature = hash_hmac(
            'sha256',
            json_encode($payload),
            config('services.n8n.hmac_secret')
        );

        Http::timeout(20)
            ->withHeaders([
                'X-App-Signature' => $signature,
            ])
            ->post(config('services.n8n.support_webhook'), $payload);
    }
}

There are a few deliberate choices here.

First, the message is stored before the automation is triggered. If n8n is down, the customer message is not lost. Laravel can retry the job, and support staff can still see the conversation.

Second, the payload includes only the context the agent needs. You should not dump your entire customer object into the LLM pipeline. Send the minimum useful fields: plan, account status, recent messages, conversation ID. If the agent needs more, it should request it through controlled tools.

Third, the conversation status check matters. If a human has already taken over, the agent should not continue processing.

🚨 Production warning: do not let your frontend call n8n directly. Keep the orchestration endpoint server-side, authenticated, and signed.

If you are using self-hosted n8n, keep the webhook internal where possible. If n8n is external, use HTTPS, secrets, signature verification, rate limiting, and IP restrictions if your infrastructure supports them.

3. Build a RAG pipeline that fails predictably

Most RAG systems fail in a very specific way: they retrieve something, so they assume they retrieved the right thing.

Support documents make this worse. Help centers contain similar-looking articles: pricing, refunds, plan limits, API errors, account deletion, data retention. A vector search may return a chunk that is semantically close but operationally wrong.

Scenario:
Your docs say, “Refunds are handled according to the terms of the plan.” The customer asks, “Can I get a refund because my team was accidentally charged after we downgraded?” The retrieval system finds the refund article. The LLM writes a nice answer. But the actual answer depends on the customer’s billing history, downgrade date, and your internal exception policy.

The RAG pipeline succeeded technically and failed support-wise.

To avoid this, your RAG pipeline needs more than text chunks. It needs metadata.

At ingestion time, each chunk should carry enough information for later filtering and confidence scoring:

$chunks[] = [
    'external_id' => "{$document->id}:{$index}",
    'text' => $chunkText,
    'metadata' => [
        'document_id' => $document->id,
        'title' => $document->title,
        'product' => $document->product,
        'plan_scope' => $document->plan_scope,
        'region' => $document->region,
        'audience' => $document->audience,
        'policy_level' => $document->policy_level,
        'updated_at' => $document->updated_at->toIso8601String(),
        'source_url' => $document->source_url,
    ],
];

Useful metadata includes:

  • product: Which product or feature does this apply to?
  • plan_scope: Free, Pro, Enterprise, all plans?
  • region: EU, US, global?
  • audience: End user, admin, developer?
  • policy_level: Informational, account-specific, legal, billing?
  • updated_at: How stale is this?
  • source_url: Where can a human verify it?
  • requires_human_review: Does this topic always need human judgment?

Chunking also matters more than teams expect. For support documentation, chunks that preserve headings and policy context usually outperform arbitrary fixed-size chunks. A chunk that says “Refunds are not available after 30 days” without the heading “Enterprise annual plans” can become dangerously misleading.

A reasonable starting point for support docs:

  • Chunk around headings where possible.
  • Keep chunks roughly 300 to 800 tokens.
  • Use modest overlap, perhaps 50 to 100 tokens.
  • Preserve section titles in the chunk text.
  • Store the document title and section path in metadata.
  • Avoid merging unrelated FAQ answers into one chunk.

In n8n, your ingestion workflow can receive documents from Laravel, split them, add metadata, generate embeddings, and store them in your vector database. If you already use Postgres with pgvector, that can be a very sensible default. If you need more advanced filtering or scale, a dedicated vector database such as Qdrant may be worth considering.

The key is not the brand of vector database. The key is that retrieval returns not only text, but also enough metadata to answer these questions:

  1. Is this source recent?
  2. Does it apply to this customer’s plan?
  3. Does it apply to this product area?
  4. Is it informational or policy-sensitive?
  5. Are multiple sources agreeing?
  6. Is the best match meaningfully better than the next best match?

If your retrieval layer cannot answer these, your agent will be forced to guess.

💡 Practical note: if a document is too sensitive to send to your LLM provider, it needs special handling before you casually embed it into a general support pipeline.

4. Turn retrieval quality into a confidence signal you can trust

A lot of teams ask the LLM, “Are you confident?” and then trust the answer.

That is a mistake.

LLMs can be confidently wrong. They are also often poorly calibrated about their own uncertainty, especially when given retrieved context that sounds relevant. A better approach is to combine several signals into a structured confidence score.

Useful signals include:

  • Top retrieval score
  • Score margin between the first and second result
  • Number of relevant results above a threshold
  • Source freshness
  • Metadata match with the customer’s plan/product
  • Whether required entities are present
  • Whether retrieved sources contradict each other
  • Whether the answer can cite specific sources
  • Whether the question is account-specific or policy-exceptional

In n8n, you can compute a retrieval confidence signal in a Code node before asking the LLM to generate the final response.

Example:

const matches = $json.retrieval.matches ?? [];

const daysOld = iso => {
  return (Date.now() - Date.parse(iso)) / 86400000;
};

const top = matches[0] ?? null;
const runnerUp = matches[1] ?? null;

const topScore = top?.score ?? 0;
const margin = runnerUp ? topScore - runnerUp.score : topScore;

const hasRecentSource = matches.some(match => {
  return daysOld(match.metadata?.updated_at) < 180;
});

const appliesToPlan = matches.some(match => {
  const scope = match.metadata?.plan_scope;

  return scope === 'all' || scope === $json.customer.plan;
});

const hasPolicySensitiveSource = matches.some(match => {
  return match.metadata?.policy_level === 'sensitive';
});

let retrievalConfidence = 'low';

if (
  topScore >= 0.78 &&
  margin >= 0.05 &&
  hasRecentSource &&
  appliesToPlan
) {
  retrievalConfidence = 'high';
} else if (topScore >= 0.62 && hasRecentSource) {
  retrievalConfidence = 'medium';
}

return [{
  json: {
    ...$json,
    retrieval_confidence: retrievalConfidence,
    retrieval_flags: {
      top_score: topScore,
      margin,
      has_recent_source: hasRecentSource,
      applies_to_plan: appliesToPlan,
      has_policy_sensitive_source: hasPolicySensitiveSource,
      match_count: matches.length,
    },
  },
}];

The exact thresholds are not universal. Vector databases and embedding models return scores differently. Some use cosine similarity, some use distance, some use normalized scores. You need to calibrate thresholds against your own documents and queries.

But the pattern is important: do not reduce confidence to one number chosen by vibes.

You also want the LLM to return structured output, not a free-form paragraph that you have to parse with hope.

A useful response schema looks like this:

{
  "answer": "string",
  "needs_human": true,
  "confidence": "high | medium | low",
  "reason": "string",
  "citations": [
    {
      "document_id": "string",
      "title": "string",
      "section": "string",
      "source_url": "string"
    }
  ],
  "clarifying_question": "string | null"
}

In your prompt, make the rules explicit:

You are a support assistant. Answer only if the retrieved context is sufficient.
If the request depends on the customer's account state, billing history, or an exception to policy, set needs_human to true.
If the retrieved sources are outdated, contradictory, or insufficient, set needs_human to true.
Do not invent policies.
If you need more information from the customer, provide a clarifying_question.
Return valid JSON only.

This does not make the model perfect. But it gives your workflow a machine-readable decision point.

The agent can now branch:

  • needs_human === true → handoff flow
  • confidence === high and no sensitive flags → answer
  • confidence === medium and missing info → ask clarifying question
  • confidence === low → avoid answering and escalate or collect more information

This is where the system starts to feel mature. It is no longer just “retrieve and generate.” It is retrieve, evaluate, decide, and act.

5. Encode “ask for help” as explicit policy, not model vibes

The LLM can suggest escalation, but it should not be the only gate.

You need explicit policy rules. These rules should live somewhere inspectable: a Code node in n8n, a policy class in Laravel, or both. The point is that escalation should be deterministic enough to reason about, test, and change without redeploying a prompt and praying.

Good handoff triggers include:

TriggerExample
Customer explicitly asks for a human“Can I talk to someone?”
Account-specific mutation“Change my billing email.”
Billing exception“I was promised a discount.”
Refund dispute“I want my money back.”
Legal or compliance language“This violates GDPR.”
Security incident“Someone accessed my account.”
Low retrieval confidenceNo strong matching docs
Contradictory sourcesTwo policies disagree
Repeated failureAgent already misunderstood twice
Sensitive dataPII, payment details, credentials
Negative sentiment escalatingCustomer is becoming angry
Tool failureBackend lookup failed or timed out

Some of these can be detected with simple rules. Some need classification. The important part is that the final decision is not hidden inside a prompt.

In n8n, a policy node might look like this:

const intent = $json.intent;
const retrieval = $json.retrieval_flags ?? {};
const customer = $json.customer ?? {};

const sensitiveIntents = [
  'billing_exception',
  'refund_request',
  'refund_dispute',
  'account_closure',
  'legal_complaint',
  'security_incident',
  'payment_failure',
  'data_deletion_request',
];

const explicitHumanRequest = Boolean($json.explicit_human_request);
const lowRetrievalConfidence = $json.retrieval_confidence === 'low';
const repeatedFailure = ($json.failed_attempts ?? 0) >= 2;
const sensitivePlanState = ['past_due', 'suspended', 'fraud_review'].includes(customer.account_status);
const policySensitiveSource = Boolean(retrieval.has_policy_sensitive_source);

const shouldEscalate = [
  explicitHumanRequest,
  sensitiveIntents.includes(intent),
  lowRetrievalConfidence,
  repeatedFailure,
  sensitivePlanState,
  policySensitiveSource,
].some(Boolean);

return [{
  json: {
    ...$json,
    should_escalate: shouldEscalate,
    escalation_reasons: [
      explicitHumanRequest ? 'explicit_human_request' : null,
      sensitiveIntents.includes(intent) ? `sensitive_intent:${intent}` : null,
      lowRetrievalConfidence ? 'low_retrieval_confidence' : null,
      repeatedFailure ? 'repeated_failure' : null,
      sensitivePlanState ? 'sensitive_account_state' : null,
      policySensitiveSource ? 'policy_sensitive_source' : null,
    ].filter(Boolean),
  },
}];

This is intentionally blunt.

You might be tempted to make the policy smarter by letting the model decide everything. Resist that. Support systems need explainability. If a support lead asks, “Why did the bot promise a refund?” you want a clear answer: it should not have been allowed to answer that intent in the first place.

A good rule of thumb:

  • Documentation questions can often be answered automatically.
  • Account-specific actions should require tools and validation.
  • Policy exceptions should usually go to humans.
  • Legal, security, and payment disputes should almost always go to humans.

Also, be careful with sentiment analysis. It can be useful, but it should not become a magical escalation oracle. A frustrated customer does not always need a human. Sometimes the agent can solve the problem quickly. Conversely, a polite customer can be requesting something extremely sensitive.

Use sentiment as a signal, not as the judge.

⚠️ Gotcha: if your agent can perform actions, escalation policy must be enforced around the action itself, not only around the final answer.

If the agent has a tool that can issue refunds, update account details, or cancel subscriptions, the handoff rules need to apply before that tool is allowed to run.

6. Give the model a handoff tool it can call

A clean way to let the agent ask for help is to expose a dedicated tool:

{
  "name": "request_human_handoff",
  "description": "Request a human support agent when the issue is account-specific, sensitive, unclear, or outside the assistant's authority.",
  "parameters": {
    "type": "object",
    "properties": {
      "reason": {
        "type": "string",
        "enum": [
          "account_specific_action",
          "billing_exception",
          "refund_request",
          "security_concern",
          "legal_or_compliance",
          "insufficient_documentation",
          "customer_requested_human",
          "repeated_misunderstanding"
        ]
      },
      "severity": {
        "type": "string",
        "enum": ["normal", "urgent"]
      },
      "summary": {
        "type": "string",
        "description": "A short factual summary of the customer's issue."
      },
      "missing_information": {
        "type": "array",
        "items": {
          "type": "string"
        }
      }
    },
    "required": ["reason", "summary"]
  }
}

This gives the model a socially acceptable way to stop.

Without a handoff tool, models often do one of two bad things:

  1. They keep answering when they should not.
  2. They say, “Please contact support,” without creating an actual support handoff.

The second one is especially annoying. It creates the illusion of escalation while leaving the customer stranded.

In n8n, when the model calls the handoff tool, your workflow should not blindly trust the payload. It should validate it, enrich it with conversation context, and send it to Laravel.

Laravel can expose a handoff endpoint:

// routes/api.php

Route::post('/n8n/handoffs', StoreHandoffFromN8n::class)
    ->name('n8n.handoffs.store');
// app/Http/Controllers/Api/StoreHandoffFromN8n.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Jobs\NotifySupportTeamAboutHandoff;
use App\Models\Conversation;
use App\Models\Handoff;
use Illuminate\Http\Request;

class StoreHandoffFromN8n extends Controller
{
    public function __invoke(Request $request)
    {
        abort_unless($this->signatureIsValid($request), 403);

        $data = $request->validate([
            'handoff_ref' => ['required', 'uuid'],
            'conversation_id' => ['required', 'exists:conversations,id'],
            'reason' => ['required', 'string', 'max:100'],
            'severity' => ['required', 'in:normal,urgent'],
            'summary' => ['required', 'string', 'max:2000'],
            'missing_information' => ['nullable', 'array'],
            'missing_information.*' => ['string', 'max:255'],
            'citations' => ['nullable', 'array'],
            'escalation_reasons' => ['nullable', 'array'],
        ]);

        $conversation = Conversation::findOrFail($data['conversation_id']);

        $handoff = Handoff::firstOrCreate(
            [
                'external_ref' => $data['handoff_ref'],
            ],
            [
                'conversation_id' => $conversation->id,
                'reason' => $data['reason'],
                'severity' => $data['severity'],
                'summary' => $data['summary'],
                'missing_information' => $data['missing_information'] ?? [],
                'citations' => $data['citations'] ?? [],
                'escalation_reasons' => $data['escalation_reasons'] ?? [],
                'status' => 'open',
            ]
        );

        if ($handoff->wasRecentlyCreated) {
            $conversation->update([
                'status' => 'waiting_human',
            ]);

            NotifySupportTeamAboutHandoff::dispatch($handoff);
        }

        return response()->json([
            'handoff_id' => $handoff->id,
            'status' => $handoff->status,
        ], 201);
    }

    private function signatureIsValid(Request $request): bool
    {
        $expected = hash_hmac(
            'sha256',
            $request->getContent(),
            config('services.n8n.hmac_secret')
        );

        return hash_equals(
            $expected,
            (string) $request->header('X-N8n-Signature', '')
        );
    }
}

There are several important details here.

The handoff_ref makes the request idempotent. If n8n retries after a timeout, you do not want to create five duplicate handoffs. firstOrCreate gives you a simple deduplication mechanism.

The signature check prevents random callers from creating handoffs. If your n8n instance is compromised or your webhook URL leaks, this becomes important.

The conversation status change is the real handoff. Once Laravel marks the conversation as waiting_human, subsequent agent processing should stop.

You can model conversation states as a simple enum-like set:

  • active_agent_allowed
  • waiting_human
  • human_owned
  • resolved
  • closed

The exact names matter less than the discipline. The agent should only operate when the state allows it.

🔍 Why this matters: the handoff is not the LLM saying “maybe.” It is a persisted state change in your application.

One subtle point: the model can call the handoff tool too eagerly. If every mildly ambiguous question escalates, your support team will hate the system. That is why the policy layer should still exist. The model can propose escalation, but your workflow can also suppress false positives or force a clarifying question first.

For example:

  • If the customer asks, “How do I invite a teammate?” and retrieval confidence is high, answer.
  • If the customer asks, “Can you refund me?” do not answer from docs. Escalate or collect details depending on your support process.
  • If the customer says, “That didn’t help,” after two attempts, escalate.

The best support agents are not the ones that never ask for help. They are the ones that ask at the right time.

7. Hand humans a packet, not a wall of chat history

The handoff experience is where many AI support systems collapse.

The bot decides it needs help. It creates a ticket. Then the human agent opens the ticket and sees 68 messages, three misunderstood intents, a bunch of retrieved documentation snippets, and no summary.

Now the human has to do two jobs: help the customer and reverse-engineer the bot.

A better handoff contains a structured packet.

The packet should include:

  • Who the customer is
  • What the customer appears to want
  • What has already been attempted
  • What information is missing
  • Which sources were considered
  • Why the agent escalated
  • What the suggested next step is
  • A link to the full transcript

In Laravel, you can build this from the handoff record:

// app/Support/HandoffPacket.php

namespace App\Support;

use App\Models\Handoff;

class HandoffPacket
{
    public static function for(Handoff $handoff): array
    {
        $conversation = $handoff->conversation;

        return [
            'conversation_id' => $conversation->id,
            'customer' => [
                'id' => $conversation->user_id,
                'name' => $conversation->user->name,
                'email' => $conversation->user->email,
                'plan' => $conversation->user->subscription_plan,
                'account_status' => $conversation->user->account_status,
            ],
            'status' => $handoff->status,
            'severity' => $handoff->severity,
            'reason' => $handoff->reason,
            'summary' => $handoff->summary,
            'missing_information' => $handoff->missing_information,
            'escalation_reasons' => $handoff->escalation_reasons,
            'sources_considered' => $handoff->citations,
            'transcript_url' => route('admin.conversations.show', $conversation),
            'handoff_created_at' => $handoff->created_at->toIso8601String(),
        ];
    }
}

This packet can power a Slack notification, an internal admin screen, an email, or a support ticket integration.

A good Slack message for a handoff should not be:

New AI handoff.

It should be closer to:

Urgent billing handoff
Customer: Maya Chen (Pro, active)
Summary: Customer reports duplicate charge after plan downgrade.
Missing: invoice ID, downgrade date
Reason: billing_exception
Sources considered: Refund Policy v3, Plan Downgrade FAQ
Transcript: https://support.example.com/admin/conversations/8123

That is actionable.

The human agent should not need to ask the customer to repeat everything. The whole point of the AI layer is not only to deflect tickets, but to make the tickets that remain higher quality.

You should also think about takeover races.

Scenario:
The customer sends a message. The agent starts processing. Ten seconds later, a support agent opens the conversation and replies. Then the AI finishes and posts its own reply after the human reply.

This is confusing and unprofessional.

Prevent it by checking conversation state at the moment you write the assistant reply:

// app/Http/Controllers/Api/StoreAgentReplyFromN8n.php

public function __invoke(Request $request)
{
    abort_unless($this->signatureIsValid($request), 403);

    $data = $request->validate([
        'conversation_id' => ['required', 'exists:conversations,id'],
        'message_id' => ['required', 'exists:messages,id'],
        'reply' => ['required', 'string', 'max:8000'],
        'confidence' => ['required', 'in:high,medium,low'],
        'citations' => ['nullable', 'array'],
    ]);

    $conversation = Conversation::lockForUpdate()->find($data['conversation_id']);

    if (! in_array($conversation->status, ['active_agent_allowed', 'waiting_customer'], true)) {
        return response()->json([
            'ignored' => true,
            'reason' => 'conversation_not_agent_actionable',
        ], 409);
    }

    $conversation->messages()->create([
        'role' => 'assistant',
        'body' => $data['reply'],
        'confidence' => $data['confidence'],
        'citations' => $data['citations'] ?? [],
        'source_message_id' => $data['message_id'],
    ]);

    return response()->json(['stored' => true], 201);
}

The lock is not just paranoia. If Laravel is receiving both human replies and agent replies, you need a deterministic way to avoid interleaved state.

Also, keep an audit trail. Store the prompt inputs, retrieved chunks, model outputs, tool calls, and handoff decisions. You do not need to show all of this to the customer, but you absolutely need it for debugging.

When the agent says something wrong, you want to know whether the problem was:

  • Bad retrieval
  • Bad chunking
  • Stale docs
  • Bad prompt
  • Bad tool data
  • Bad policy rule
  • Model hallucination

Without logs, every failure becomes a mystery.

8. Test escalation like a production feature

Teams often test the happy path: the bot answers a documentation question correctly. Then they deploy and discover that the bot also cheerfully answers billing disputes, legal threats, and account deletion requests.

Escalation needs tests.

You need at least three layers:

  1. Unit tests for policy rules
  2. Integration tests for handoff endpoints
  3. Evaluation tests for real support questions

For Laravel integration tests, test that signed handoff requests create handoffs and unsigned requests do not.

Example using Pest:

it('creates a handoff when n8n sends a valid signed request', function () {
    $conversation = Conversation::factory()->create();

    $payload = [
        'handoff_ref' => (string) str()->uuid(),
        'conversation_id' => $conversation->id,
        'reason' => 'billing_exception',
        'severity' => 'urgent',
        'summary' => 'Customer reports duplicate charge after downgrade.',
        'missing_information' => ['invoice_id'],
        'citations' => [],
        'escalation_reasons' => ['sensitive_intent:billing_exception'],
    ];

    $signature = hash_hmac(
        'sha256',
        json_encode($payload),
        config('services.n8n.hmac_secret')
    );

    $response = $this->postJson(
        route('n8n.handoffs.store'),
        $payload,
        ['X-N8n-Signature' => $signature]
    );

    $response->assertCreated();

    $this->assertDatabaseHas('handoffs', [
        'conversation_id' => $conversation->id,
        'reason' => 'billing_exception',
        'severity' => 'urgent',
        'status' => 'open',
    ]);

    $this->assertDatabaseHas('conversations', [
        'id' => $conversation->id,
        'status' => 'waiting_human',
    ]);
});

it('rejects an unsigned handoff request', function () {
    $conversation = Conversation::factory()->create();

    $payload = [
        'handoff_ref' => (string) str()->uuid(),
        'conversation_id' => $conversation->id,
        'reason' => 'billing_exception',
        'severity' => 'urgent',
        'summary' => 'Suspicious unsigned handoff attempt.',
    ];

    $this->postJson(route('n8n.handoffs.store'), $payload)
        ->assertForbidden();
});

For n8n policy logic, export representative payloads and test them outside the workflow if possible. Even a small test harness is better than manually clicking through the workflow every time you change a threshold.

Build a fixture set like this:

{
  "case": "billing_exception",
  "customer_message": "I was charged twice after downgrading.",
  "intent": "billing_exception",
  "retrieval_confidence": "medium",
  "expected": "escalate"
}

Then test cases such as:

CaseExpected behavior
“How do I reset my password?”Answer
“Where is the API rate limit documentation?”Answer
“Can I get a refund because my team was charged incorrectly?”Escalate
“I need to talk to a person.”Escalate
“Someone accessed my account without permission.”Escalate urgently
“The docs say X, but my account shows Y.”Escalate or ask for details
“Your previous answer did not help.” twiceEscalate

You should also build an evaluation dataset from real anonymized support conversations. Do not only test easy documentation questions. Include the messy ones: ambiguous intents, angry customers, partial information, contradictory account states, and requests that sound simple but are not.

For production monitoring, track:

  • Escalation rate
  • False escalation rate
  • Missed escalation rate
  • Time from customer message to human handoff
  • Time from handoff to first human reply
  • Resolution rate after handoff
  • Customer satisfaction after agent answer
  • Customer satisfaction after human answer
  • Retrieval score distribution
  • Tool failure rate
  • Duplicate handoff rate
  • Stale-document retrieval rate

A sudden change in escalation rate is often more interesting than raw accuracy. If escalation drops overnight, the agent may have become overconfident. If escalation spikes, your retrieval pipeline may have broken or your docs may have become mismatched.

🧠 The important part: do not evaluate only answer quality. Evaluate decision quality.

An agent that gives a perfect answer to the wrong question is still failing. An agent that escalates slightly too often but never makes unauthorized promises may be acceptable in early production. An agent that rarely escalates but occasionally invents refund policy is not.

9. A practical rollout plan

If I were shipping this into a real product, I would not try to make the agent fully autonomous on day one.

I would roll it out in stages.

Stage 1: Draft assistant, not customer-facing agent

Start by using RAG to draft replies for human support agents.

The agent reads the customer message, retrieves docs, and proposes an answer. The human reviews and sends it.

This gives you:

  • Real retrieval quality data
  • Real escalation patterns
  • Fewer customer-facing mistakes
  • A growing evaluation dataset

This stage is underrated. It exposes bad chunks, stale docs, and ambiguous policies before customers feel the pain.

Stage 2: Answer only low-risk categories

Pick narrow categories where documentation answers are safe:

  • API usage
  • Feature explanations
  • Navigation help
  • Public pricing questions
  • Basic setup steps

Do not start with refunds, account mutations, or billing exceptions.

Stage 3: Add clarifying questions

Before allowing direct answers in medium-risk areas, let the agent collect missing information:

  • Which plan are you on?
  • What error message do you see?
  • What date did the charge occur?
  • Are you the account owner?

This is often safer than answering immediately.

Stage 4: Add tools with strict boundaries

Once retrieval and handoff are stable, you can give the agent controlled tools:

  • Read subscription status
  • Read recent invoices
  • Read feature flags
  • Read ticket status
  • Create a support ticket
  • Request a human handoff

Be careful with write tools. If the agent can update account data, you need:

  • Explicit permission checks
  • Audit logging
  • Idempotency
  • Rate limiting
  • Human approval for high-risk actions
  • Tool-level escalation rules

Stage 5: Let the agent answer customers directly

Only do this when you can answer these questions confidently:

  1. What questions is the agent allowed to answer?
  2. What questions must never be answered automatically?
  3. How do we know retrieval was good enough?
  4. How do we detect account-specific issues?
  5. How do humans take over?
  6. How do we prevent the agent from replying after takeover?
  7. How do we test regressions when docs change?
  8. How do we monitor bad answers?

If you cannot answer those, the system is not ready for unsupervised customer replies.

Decision guide

SituationRecommended approach
Early prototypen8n workflow, vector retrieval, human review only
Small product with simple docsAgent answers low-risk questions, escalates everything else
Billing-heavy productStrict policy rules, human approval for billing topics
Regulated or legal-sensitive domainConservative escalation, audit logs, human-first drafting
High support volumeQueues, evaluation dataset, monitoring, staged rollout
Multiple products or plansMetadata filtering is mandatory before customer-facing answers

What I would choose

For a serious production support agent, I would choose:

  • Laravel as the source of truth for conversations and handoffs
  • n8n for orchestration, retrieval, and LLM calls
  • A vector store with strong metadata filtering
  • Structured JSON output from the model
  • Explicit escalation policy outside the prompt
  • A dedicated handoff tool
  • Human takeover state enforced in Laravel
  • Full audit logs for prompts, retrieval, and tool calls
  • A staged rollout beginning with draft replies for humans

The goal is not to build a chatbot that never asks for help.

The goal is to build an AI support agent that knows the difference between a documentation question and a situation where help is the correct answer.

Post a Comment

Previous Post Next Post