# Human handoff and team replies

Page: https://docs.sonecentral.com/en/insan-devri
Markdown: https://docs.sonecentral.com/en/insan-devri.md

> How conversations move to your team and how staff replies reach the visitor.

Every API conversation appears in Sonecentral's **Mesajlar** (Messages) screen with source **API**. The visitor is called **API ziyaretçisi** (API visitor), because their identity is unknown. Your team can read, take over and reply there.

## How handoff works

As on WhatsApp, the assistant hands a conversation to the team when the visitor asks for a person, the assistant cannot answer, or business rules require it. It tells the visitor about the handoff.

A staff member writing or sharing a product, service or document in Messages also moves the conversation to the team. The assistant stops replying until the team returns it to the assistant.

The turn's `status` indicates the outcome:

| `status` | Outcome | Client action |
| --- | --- | --- |
| `answered` | The assistant replied. | None. |
| `handoff` | This turn moved the conversation to the team and sent a message, usually explaining the handoff. | Display the message and start polling replies. |
| `human` | The team owns the conversation. The assistant did not reply; the message reached the team. | Start or continue polling replies. |
| `silent` | No reply needed. | None. |

`status` appears in each format:

- Chat Completions: `choices[0].message.sone.status` for non-streamed responses, or top-level `sone.status` on the final stream chunk.
- Responses: top-level `sone.status`.
- Vercel AI SDK: `messageMetadata.status` on `finish`.

Visitors can keep writing during handoff. Send messages through the usual endpoints; they return `human` and reach the team.

**Always send the conversation ID.** Team-owned conversations cannot be continued using history or `useChat`'s `id` alone. A client retaining only `role`/`content`, or only the browser-generated ID, starts a separate conversation where the assistant replies. See [Conversation lookup](https://docs.sonecentral.com/en/sohbet-surekliligi.md#sohbet-nasil-bulunur).

Keep sending the ID after ownership returns to the assistant. Without it, continuation requires the assistant's own generated latest reply, a verbatim history including it, and history starting at the first message. Stock phrases, greetings and staff replies are insufficient. Polling also needs the conversation ID. Add an `Idempotency-Key` to each message; a retry without message identity after staff write can otherwise duplicate it.

## Read team replies

Staff replies do not appear in the original stream, since they can arrive later. Read conversation messages instead:

```http
GET https://pb.sonecentral.com/api/v1/conversations/{id}/messages?after={last_message_id}
```

```bash
curl "https://pb.sonecentral.com/api/v1/conversations/conv_x8k2m9p4q7w1r3t/messages?after=msg_p4q7w1x8k2m9a5z" \
  -H "Authorization: Bearer $SONE_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "msg_v2k8r4t1m9q6w3e",
      "role": "user",
      "type": "text",
      "text": "Would Thursday at 15:00 work?",
      "created_at": 1760867520
    },
    {
      "id": "msg_s7t4f2k9w1q3n8d",
      "role": "staff",
      "type": "text",
      "text": "Hello, I am Elif. I noted your request for Thursday at 15:00 and will reply here once confirmed.",
      "created_at": 1760867538
    }
  ],
  "has_more": false,
  "mode": "team",
  "reply_pending": false,
  "first_id": "msg_v2k8r4t1m9q6w3e",
  "last_id": "msg_s7t4f2k9w1q3n8d"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `after` | string | Last message ID you have read. Returns only newer messages. Omit to start at the beginning. |
| `limit` | integer | Page size from 1 to 100. Out-of-range values are ignored and use 50. Default: 50. |

| Field | Meaning |
| --- | --- |
| `data` | Oldest-first `SoneMessage` array. Staff can also send products, services, documents and buttons. |
| `has_more` | More messages exist. Fetch immediately using `last_id`. |
| `mode` | `"team"` or `"assistant"`. Assistant ownership does not imply the reply has finished. |
| `reply_pending` | The assistant has a running, queued or recoverable reply. Continue polling even with `mode: "assistant"`. |
| `first_id`, `last_id` | IDs of the first and last messages on this page. Omitted when `data` is empty. |

- All roles are included: visitor `user`, assistant `assistant`, and team `staff`. Do not redraw visitor messages already shown.
- Always advance `after` to response `last_id`. Visitor messages also advance the cursor, preventing repeated reads.
- An `after` outside this conversation returns `400 invalid_cursor`. Clear the cursor and read from the beginning.
- A non-API conversation, wrong workspace, or assistant unavailable to the key returns `404 conversation_not_found`. Any key in the workspace allowed to call the same assistant can read it.
- Every read counts toward [limits](https://docs.sonecentral.com/en/limitler.md). Publishable-key reads have a separate hourly allowance from message sending.

## Staff shares

Staff can share inventory products, services and Knowledge Base documents from Messages, just as on WhatsApp. They use the same structure as assistant shares, with `role: "staff"`:

| Shared item | `type` | Data |
| --- | --- | --- |
| Product or service | `catalog` | One record per staff share in `catalog.items`; `kind` is `inventory` for products and `offering` for services. |
| Document | `file` | Signed document download URL in `file.url`. |

```json
{
  "id": "msg_k8r3t6w1q9m2x5c",
  "role": "staff",
  "type": "catalog",
  "text": "Haircut\nIncludes wash and blow-dry.\n750₺\n45 minutes\nhttps://www.ornek.com/hizmet/sac-kesimi",
  "created_at": 1760867590,
  "catalog": {
    "format": "card",
    "items": [
      {
        "id": "o5n2k8w4q1t7m3x",
        "kind": "offering",
        "title": "Haircut",
        "description": "Includes wash and blow-dry.",
        "price": "750₺",
        "detail": "45 minutes",
        "url": "https://www.ornek.com/hizmet/sac-kesimi",
        "button_title": "Hizmeti incele"
      }
    ]
  }
}
```

- A share moves ownership to the team and appears in the message list, not the original stream. If staff share during an assistant conversation, the next visitor turn returns `human`. Poll after the last message from the previous turn to retrieve the share.
- If a share answers a visitor message and you retry it with the same `Idempotency-Key`, the share also appears among that turn's messages.
- Every share includes `text` for text-only clients. Long catalog fallback text may be shortened; `catalog.items` remains complete.
- Render cards and download files as for assistant shares: [Rich messages](https://docs.sonecentral.com/en/zengin-mesajlar.md).

## Conversation state

To check ownership without reading messages:

```http
GET https://pb.sonecentral.com/api/v1/conversations/{id}
```

```json
{
  "id": "conv_x8k2m9p4q7w1r3t",
  "object": "conversation",
  "created_at": 1760866000,
  "model": "k3m9x2p7q1w8e4r",
  "mode": "team",
  "reply_pending": false
}
```

`model` identifies the bound assistant. `mode` is `"assistant"` or `"team"`. `reply_pending` indicates an outstanding assistant reply. Use these fields to resume polling when a page reopens.

## Polling frequency

Poll every five seconds while the widget is open and the team owns the conversation. Stop in background tabs and closed widgets. On return, poll once to catch up.

A publishable key allows 20,000 reads/hour. One visitor polling every five seconds uses 720, supporting about 27 simultaneous waiting visitors per key. For larger audiences:

- Poll every five seconds for the first two minutes after handoff or the latest staff reply, then every 30–60 seconds.
- After 30 minutes without new messages, stop until the visitor writes or reopens the widget.
- On `429`, wait for `Retry-After`.

See [Limits](https://docs.sonecentral.com/en/limitler.md).

```js
// Read staff and assistant messages after the cursor, through all pages.
async function readTeamReplies(conversationId, cursor) {
  const messages = [];
  let mode = 'team';
  let replyPending = true;
  for (;;) {
    const url = new URL(`https://pb.sonecentral.com/api/v1/conversations/${encodeURIComponent(conversationId)}/messages`);
    if (cursor) url.searchParams.set('after', cursor);
    const response = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
    if (!response.ok) break;
    const page = await response.json();
    messages.push(...page.data.filter((message) => message.role !== 'user'));
    cursor = page.last_id ?? cursor;
    mode = page.mode;
    replyPending = page.reply_pending;
    if (!page.has_more) break;
  }
  return { messages, cursor, mode, replyPending }; // stop only when mode is 'assistant' and replyPending is false
}
```

Start the cursor at the last message ID from the last turn. Store and reuse the returned `cursor` after every poll. Complete example: [Embedded chat widget](https://docs.sonecentral.com/en/sohbet-penceresi.md).

## When the assistant cannot reply

In an existing conversation, `ai_turn_ceiling`, `assistant_quota_exhausted`, `plan_required` or `assistant_paused` means the visitor message was stored and reached your team, but the assistant did not reply. Ownership remains `mode: "assistant"` until a staff member writes or shares a record. Their reply appears in the message list.

After these errors, keep polling even with assistant ownership until staff reply or the visitor's next message is answered.

The same errors on a first message store nothing and create no conversation. Ask the visitor to retry later.

## Returning to the assistant

Staff can return ownership from Messages. `mode` becomes `"assistant"` immediately. If the last visitor message is unanswered, the assistant answers it too, without opening another HTTP stream. Continue reading while `reply_pending` is true and render both `staff` and `assistant` messages.

Stop when `has_more` is false, `mode` is `"assistant"` and `reply_pending` is false. Deduplicate by message ID against streamed messages; Vercel AI SDK's `finish` metadata `messageIds` exists for this purpose. The next visitor message is answered by the assistant again.
