# Handoff and safety

> The never-handle list, the verbatim escalation reply, truthful handoffs, handoff expiry and the never-silent rule — guarantees enforced in code, not hoped for in a prompt.

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.

## The never-handle list

`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.

## Verbatim escalation reply

`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:

```json
{
  "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`.

## Only say what you were told

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.

## Handoffs

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.

### Truthful tool results

`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.

### Human mode

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.

### Expiry

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.

### Resolving

`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.

## Business hours

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.

## Never silent

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.

## Events

| 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](/docs/en/guides/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.

## Learn more

- Work the queue in the [Handoff Desk guide](/docs/en/guides/handoff-desk/).
- See every related setting in the [settings reference](/docs/en/concepts/settings/#guardrails).
