> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flowyte.com/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Harness connector reference

> Build the harness side — authenticate with a scoped key, verify the signed webhook, then claim, reply, and resolve an escalated Flowyte conversation.

This page is for the team building the **connector** — the Hermes, OpenClaw, or custom runtime that
receives escalated Flowyte conversations and talks back and forth with the customer. It's the
technical companion to [AI Harnesses](/integrations/ai-harness), which covers the operator-side setup.

The flow: Flowyte opens a durable **escalation session**, notifies you with a **signed webhook**, and
you **claim** the session and drive it over REST — read the context, post replies (which reach the
customer in their original thread), and finally **resolve** or **return** control to the AI. Flowyte
owns the conversation record; your connector is a participant, never the source of truth.

## 1. Authenticate with a scoped key

Every REST call uses a **scoped secret key** (`flowyte_sk_…`), minted by the operator for the
destination and shown once:

```
Authorization: Bearer flowyte_sk_…
```

The key's scopes gate exactly what the connector may do — request only what you need. These scopes are
separate from `escalation_destinations:*`: a connector key works conversations but can't edit
destinations.

| Scope                 | Grants                                                                                              |
| --------------------- | --------------------------------------------------------------------------------------------------- |
| `escalations:read`    | `GET /escalations`, `GET /escalations/{id}` (context, owner only), `GET /escalations/{id}/messages` |
| `escalations:claim`   | `POST /escalations/{id}/claim`, `/heartbeat`                                                        |
| `escalations:respond` | `POST /escalations/{id}/messages`, `/typing`                                                        |
| `escalations:resolve` | `POST /escalations/{id}/resolve`, `/return`, `/request-human`                                       |

Keys are **organization- and destination-scoped**: a key only ever sees its own tenant's sessions.
Cross-tenant calls fail closed as `404`.

<Warning>
  Never expose local file paths, prompts, model names, or secrets in a reply, a resolve note, or a
  shipped log — the customer sees message text verbatim.
</Warning>

## 2. Verify the signature first

Flowyte delivers webhooks to your `endpoint_url` over its signed pipeline. The body is a stable
envelope — **route on the `channel` field in every notification**:

```jsonc theme={null}
{
  "id": "<deliveryId>",                 // also the Flowyte-Delivery header; dedupe on this
  "type": "escalation.requested",       // or escalation.test | escalation.message.created | …
  "createdAt": "2026-07-13T18:03:00Z",
  "organizationId": "org_…",
  "data": {
    "escalationId": "esc_…",
    "conversationId": "…",
    "channel": "chat",                  // chat | sms — pick the right profile before claiming
    "reason": { "type": "sales_request", "summary": "…" },
    "responseDeadlineAt": "2026-07-13T18:05:00Z",
    "test": false                       // true for a handshake escalation.test envelope
  }
}
```

### Signature headers

| Header                   | Meaning                                                                         |
| ------------------------ | ------------------------------------------------------------------------------- |
| `Flowyte-Signature`      | `hex(HMAC_SHA256(secret, "{Flowyte-Delivery}.{Flowyte-Timestamp}.{rawBody}"))`  |
| `Flowyte-Signature-Prev` | Present **only** during a 24-hour secret rotation; same scheme, previous secret |
| `Flowyte-Timestamp`      | Unix seconds (decimal string)                                                   |
| `Flowyte-Delivery`       | The delivery id (also `id` in the body) — your **dedupe key**                   |
| `Flowyte-Event`          | The event type                                                                  |

### Verification recipe

<Steps>
  <Step title="Check the timestamp">
    Reject if `|now − Flowyte-Timestamp| > 300s` (replay window).
  </Step>

  <Step title="Recompute the signature">
    Compute `hex(HMAC_SHA256(secret, Flowyte-Delivery + "." + Flowyte-Timestamp + "." + rawBody))`
    using the **raw** request bytes — re-serializing the JSON changes whitespace and breaks the match.
  </Step>

  <Step title="Compare in constant time">
    Accept only if it equals `Flowyte-Signature`, using a constant-time compare (never `==`). During a
    rotation, also accept if any secret you hold verifies `Flowyte-Signature-Prev`.
  </Step>
</Steps>

A bad signature or a stale timestamp is a `401` — do no work.

<Note>
  Webhooks are the **doorbell, not the mailbox** — they tell you something happened; the REST API is the
  source of truth. Delivery is at-least-once, so a webhook may be redelivered — dedupe on
  `Flowyte-Delivery`. Don't parse conversation content out of webhooks; read it from `/messages`.
</Note>

## 3. Claim, reply, resolve

### Claim

`POST /escalations/{id}/claim` takes ownership and starts an activity-based lease (chat ≈ 2 min,
SMS ≈ 4 h). Claiming is **idempotent for your key** — re-claiming a session you hold just re-extends
the lease — and guarded, so a second connector gets `409 claim_conflict`.

<Warning>
  **Claim with retry on `409` / `404`.** A redelivered webhook or a race can transiently `409`, and the
  session may not be visible for a beat (`404`) right after it's created — back off briefly and retry a
  few times.
</Warning>

The claim response (and `GET /escalations/{id}` as the owner) returns the **context package**: the last
N transcript turns (each with its sequence number), the escalation reason, any verified identifiers
(SMS: the caller's phone), and completed or failed tool actions (names and status only). It's returned
**only** to the claiming owner and is rebuilt at claim time, so you start current with any turns that
arrived while the notification was in flight.

Claim can also fail with `409 destination_unavailable` (the harness was disconnected or the destination
is inactive) or `403 plan_required` (the organization is below Starter — the handshake test is exempt).

### Read and sync

Every turn — customer **and** your own replies — carries the conversation's sequence number (`seq`),
the single ordering and dedup key. To catch up, or after a reconnect, poll
`GET /escalations/{id}/messages?after_seq=N` and process strictly increasing `seq` values. Never assume
a webhook told you everything; the `/messages` mailbox is authoritative and ordered.

### Reply

```jsonc theme={null}
POST /escalations/{id}/messages
{ "client_message_id": "your-stable-id", "text": "Hi, this is Dana from Sales." }
```

* **`client_message_id` is your idempotency key.** A replay with the same id returns the **original**
  receipt (`{ messageId, seq, delivered }`) instead of double-sending. Use one stable id per logical
  message and reuse it across retries.
* The reply is delivered to the customer in their original thread — chat shows a "specialist has
  joined" divider; SMS sends a text.
* **Media** (`mediaUrls`) is chat-only; on SMS it returns `422 media_unsupported_channel`.
* **SMS compliance:** a reply to an opted-out recipient returns `409 sms_suppressed`; a quiet-hours
  send returns `200` with `delivered: false`. These are terminal — don't retry.

Any owner action (a message, typing, heartbeat, even a `/messages` read) extends your lease. Send
`POST /escalations/{id}/heartbeat` if you go quiet but want to keep the thread;
`POST /escalations/{id}/typing` shows a typing indicator (chat only). If the lease expires, the thread
returns to the AI and you get `409 lease_expired` — re-claim to continue.

### Finish

| Call                                                               | Effect                                                       |
| ------------------------------------------------------------------ | ------------------------------------------------------------ |
| `POST /escalations/{id}/resolve` `{ "outcome": "…", "note": "…" }` | Done — the conversation closes.                              |
| `POST /escalations/{id}/return`                                    | Hand control back to the AI, which resumes the conversation. |
| `POST /escalations/{id}/request-human`                             | Route to the operator's human fallback chain.                |

## 4. Channels: chat vs SMS

The lifecycle is identical across channels — **route on the `channel` field** — but:

* **Chat** replies appear live in the website widget; typing indicators and media attachments are
  supported.
* **SMS** threads are **customer-initiated only.** You reply inside an SMS conversation the customer
  already started — you cannot open one. (Outbound initiation is coming soon, tied to Flowyte's
  outbound campaign rules.) SMS has no typing indicator and no media in v1, and it requires the
  operator's completed **A2P 10DLC (TCR) registration** — the same registration their inbound number
  already needs. See [SMS](/channels/sms).

Harness conversations require the operator's organization to be on **Starter or higher**. The handshake
test works on any plan, so setup can precede the upgrade.

## 5. The handshake test

Before a destination can route real conversations, the operator runs a handshake test
(`POST /escalation-destinations/{id}/test`) that proves your connector can talk back and forth:

1. Flowyte opens a **sandbox** escalation on a synthetic conversation and sends you a signed
   `escalation.test` webhook — a normal `escalation.requested` envelope plus `"test": true`.
2. Your connector must, within the window: **verify the signature → claim → post one message →
   resolve.**
3. Flowyte reports a step ledger (`{ webhook_delivered, claimed, message_posted, resolved }`) with an
   actionable hint on failure. Only a green run flips the destination to `active`.

Treat `escalation.test` **exactly like** `escalation.requested` — same code path. Test messages reach
no customer and are never billed, but your connector doesn't need to know that; just claim, reply,
resolve.

## Hard rules

<Warning>
  * **Verify `Flowyte-Signature`** (constant-time; 300s replay window; accept `Flowyte-Signature-Prev`
    during rotation) before doing any work.
  * **Claim with retry on `409` / `404`;** claim is idempotent for your key.
  * **Idempotent `client_message_id`** on every reply; a replay returns the original receipt.
  * **Dedupe by `seq`; gap-sync via `GET /escalations/{id}/messages?after_seq=N`.** Webhooks are the
    doorbell; the REST API is the mailbox.
  * **Route on the `channel` field** in every notification.
  * Sessions are **tenant- and destination-scoped** — you only ever see your own.
  * **Never expose local paths, prompts, model internals, or secrets** in replies, notes, or logs.
  * **SMS is customer-initiated only** and requires completed TCR registration; harness conversations
    require **Starter or higher**.
</Warning>

Back to [AI Harnesses](/integrations/ai-harness) for the operator setup, or drive the same surface from
an agent over the [MCP gateway](/get-started/mcp-gateway).
