Skip to content

Handoff and safety

View as Markdown

An AI agent that talks to your customers will meet questions it must not answer: a damage claim, a legal threat, a refund amount. K-Agent treats these as product guarantees enforced in code, so they hold no matter how a model behaves on a given day.

guardrails.never_handle lists situations the agent must never handle itself. It is on by default and comes with localized defaults:

  1. damage, injury or accident claims;
  2. legal threats, or mentions of lawyers, police or authorities;
  3. refund or compensation amounts;
  4. complaints that name an employee;
  5. requests for another customer’s data.

The list is placed last in the prompt, after your instructions and knowledge, so nothing written earlier can talk the agent out of it. It comes with composure rules: take the customer seriously, stay calm, don’t apologize on the company’s behalf, don’t speculate about fault, don’t promise anything — and never make light of it.

What the agent does next depends on the tools that are actually available. With transfer_to_human it hands the conversation to your team. Without it, it tells the customer plainly that this can’t be handled here and that they should contact you directly — it never promises a follow-up that nobody will make.

Edit the list freely; if you empty it on purpose it stays empty. Per-request overrides can never change it.

guardrails.escalation_reply is the exact reply a customer receives when the agent hands over a liability case (reason_type: "liability"). Write it once in Arabic and English, have it approved, and it is used word for word:

{
"guardrails": {
"escalation_reply": {
"ar": "شكرًا لتواصلك. حوّلنا طلبك إلى المسؤول المختص وسيتواصل معك قريبًا.",
"en": "Thank you for telling us. We've passed this to the responsible manager, who will contact you shortly."
}
}
}

When transfer_to_human succeeds with reason_type: "liability" and a reply is set:

  • tool calls still pending in that round are not executed;
  • no further model call is made;
  • the run ends handed_off with one final assistant message whose text is your reply, in the customer’s language, with metadata.replaced: true;
  • the model’s own wording is kept only in the run steps, for review.

Text sent in earlier rounds stays as its own message. With conversation.stream_mode: "auto" (the default), replies are buffered one round at a time whenever an escalation reply is configured, so streamed text can never contradict the substitution. In live mode, message.completed carries replaced: true and is the text to show.

The same applies to one-shot ask calls: the run returns the reply and a handoff, and the handoff.requested webhook fires with the run_id.

Every agent follows a grounding rule (shown in the prompt X-ray as grounding@1): prices, opening times, addresses, what a service includes — if it isn’t in the instructions or knowledge, the agent doesn’t know it. Instead of guessing, or promising to “check and get back to you” (which nobody would do), it hands the question to a person. When no handoff tool is available, it says plainly that the customer should contact you directly.

A handoff (ho_…) transfers a conversation to your team. It can be requested by:

requested_by How
agent The model calls transfer_to_human.
api Your code calls POST /v1/sessions/{session}/handoff with {reason_type, summary}, or the end user asks through the widget.
policy K-Agent itself — for example when a provider keeps failing, or a Free plan’s quota is used up.
human Someone on your team takes over a live conversation (POST /v1/sessions/{session}/takeover), or simply replies in it.

Every handoff has a reason_type — customer_requested, out_of_scope, complaint, liability or technical — and a short summary that records the topic, not an accusation. Liability handoffs get priority: "high" and are sorted first in the Handoff Desk.

transfer_to_human reports exactly what happened, and the agent may only tell the customer what the result supports. It never says “transferred” unless a handoff was recorded.

status Meaning
queued A handoff was created. Your team will pick it up; the AI stays quiet in this session.
recorded One-shot call: the handoff was recorded for your team (there is no session to pause).
recorded_closed Recorded outside business hours. The result includes next_open_local, e.g. Sunday 09:00 (Asia/Riyadh), and the agent tells the customer when a person will reply — never “shortly”.
already A handoff is already open for this conversation.
failed It could not be recorded. The agent must not claim a transfer.

For anonymous widget visitors, the agent also says they will see the reply in the chat if they keep the page open or come back on the same device.

While an agent-, API- or human-requested handoff is open, the session is in human mode (session.mode: "human"):

  • new messages are stored and shown to your team, but no AI runs;
  • POST …/messages returns 200 with run: null;
  • your team replies with role: "human_agent", and the reply reaches the widget and your session stream at once.

Technical handoffs don’t mute. A handoff caused by a provider failure, an interrupted run or an exhausted quota is recorded (row, handoff.requested webhook, outcome handed_off, notice to the customer) but the session stays in agent mode, so the next message is answered normally. A session has at most one open technical handoff.

A handoff that nobody resolves must not mute a conversation forever:

  • handoff.ttl_hours (1–720, default 24) sets the window; handoff.never_expire: true turns expiry off on purpose.
  • The window slides: claiming the handoff, or any message from your team (public reply or internal note), pushes expires_at to at least now + TTL.
  • On expiry, the handoff becomes expired, the session returns to agent mode, and your handoff.expire_message is posted as a public system notice. No AI reply starts on its own; the next customer message is answered by the agent.
  • With handoff.on_expire: "close" the session is closed instead; a new message from the customer reopens it.

POST /v1/handoffs/{handoff}/resolve with {"action": "release" | "close", "note": "…"} ends a handoff. On release, the agent takes over again: your team’s public replies become part of its history, and the optional note is given to it once as an internal note it must not quote to the customer.

With business_hours.enabled, the agent knows whether your team is online, in the agent’s time zone (locale.timezone):

  • handoff.outside_hours: "record" (default) still records handoffs out of hours, with the honest recorded_closed wording. "withhold" removes transfer_to_human while you are closed.
  • business_hours.outside_hours_ai: "answer" (default) keeps the agent answering out of hours; "message_only" sends your out_of_hours_message word for word, with no AI call.
  • Spans can run past midnight ("open": "20:00", "close": "02:00"), days you don’t list are closed, and the default schedule is Sunday to Thursday, 09:00–17:00.

Every run ends with exactly one terminal outcome. When the provider fails, K-Agent retries with backoff, then tries your fallback_models, then sends your fallback.message and hands the session to your team (fallback.handoff_on_failure, on by default). If a Free plan runs out of AI conversations, sessions get a short notice and a technical handoff — the customer never sees a billing error.

Event When
handoff.requested A handoff was recorded (any source).
handoff.assigned Someone on your team claimed it.
handoff.unclaimed Still unclaimed after handoff.notify.unclaimed_reminder_minutes (default 10).
handoff.resolved Released to the agent or closed.
handoff.expired The TTL ran out.
session.updated The session’s mode or status changed.
message.created A new message, including your team’s public replies (with role and author).

They arrive on webhooks and on the session’s event stream. Handoffs from the dashboard’s test panel are sandboxed: they never reach the Desk, webhooks or alerts.