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

# Connect your AI harness

> Wire an external agent runtime — Hermes, OpenClaw, or your own — to Flowyte end to end: endpoint URL in, connector key and signing secret out, then claim, reply, resolve, and start outbound SMS threads.

An **AI Harness** is your own agent runtime taking over a live Flowyte conversation. This guide is the
one-time setup, start to finish: what you hand Flowyte, what you get back, how to prove the connection
works, and how to run the loop afterwards. Follow it once and your harness is live.

Two things move in opposite directions, and it helps to hold them apart from the start:

<CardGroup cols={2}>
  <Card title="Flowyte → you" icon="bell">
    **Signed webhooks** to your `endpoint_url`. You verify them with the **signing secret**
    (`whsec_…`). This is the doorbell.
  </Card>

  <Card title="You → Flowyte" icon="key">
    **REST calls** to the Flowyte API. You authenticate with a scoped **connector key**
    (`flowyte_sk_…`). This is where the truth lives.
  </Card>
</CardGroup>

<Note>
  Two different secrets, two different jobs. The signing secret only ever **verifies** inbound webhooks —
  it is not an API credential. The connector key only ever **authenticates** your outbound REST calls — it
  never appears in a webhook. Never swap them.
</Note>

## Before you start

* **The API base URL.** Every path in this guide — `/escalations`, `/escalations/{id}/messages` — is
  relative to `https://builder.flowyte.com/api/v1`. So `POST /escalations` is
  `POST https://builder.flowyte.com/api/v1/escalations`. The MCP gateway, if you drive Flowyte that way
  instead, is a separate host: `https://mcp.flowyte.com/mcp`.
* An **AI Harness runtime** you control, reachable at an **absolute `https://` URL**. Flowyte
  SSRF-checks it: private, loopback and link-local targets are refused at registration *and* re-checked
  at send time.
* A Flowyte organization with **at least one agent**.
* For the `sms` channel: the organization's **A2P 10DLC (TCR) registration** must be complete — the same
  registration its inbound number already needs. See [SMS](/channels/sms).
* **Starter or higher** to run real conversations. Setup and the handshake test work on any plan, so you
  can wire everything up before upgrading.

## 1. Connect the harness integration

In the dashboard's **Integrations** page, connect your AI Harness provider — `hermes` or `openclaw` (or
`POST /integrations/{kind}/connect`). This is enablement only: it stores no credential, it just marks
the provider connected for the organization.

Nothing else on this page works until you do. Every escalation surface re-checks this server-side, so
disconnecting the integration later immediately stops new conversations reaching your harness — it does
not merely hide a button.

## 2. Give Flowyte your endpoint URL

This is the one thing **you** hand Flowyte. Register a **destination** — the record that says *where*
conversations go and *which channels* you handle.

<Steps>
  <Step title="Create the destination">
    In **Integrations → your harness → Add destination**, give it a name, your `endpoint_url`, and tick
    the channels you handle (**Chat**, **SMS**, or both). Over the API this is
    `POST /escalation-destinations` with `escalation_destinations:write`.

    Optional knobs: **context profile** (which handoff bundle you receive), **transcript window** (how
    many recent turns come with it, default 20), **claim window** (how long Flowyte waits for you to
    claim before falling back — default 120 seconds), and a fallback route.
  </Step>

  <Step title="Copy the signing secret — it is shown once">
    The create response carries `signingSecret` (`whsec_…`) **exactly once**. It is never returned by any
    later read. Store it in your secret manager before you close the drawer; if you lose it, rotate it
    (`POST /escalation-destinations/{id}/rotate-secret`) rather than hunting for it.
  </Step>
</Steps>

The destination starts `unverified`. The handshake test in step 5 is the **only** path to `active` — no
API call can set it directly.

<Warning>
  Ticking **SMS** requires the organization's 10DLC/TCR campaign to be active, or the destination refuses
  with `422 sms_registration_required`. Chat has no such requirement.
</Warning>

## 3. Mint a connector key

In the dashboard's **Developer** page, mint a secret key (`flowyte_sk_…`) for your harness. It is shown
once. Every REST call carries it:

```
Authorization: Bearer flowyte_sk_…
```

Grant only the scopes you need:

| Scope                  | Grants                                                                                                          |
| ---------------------- | --------------------------------------------------------------------------------------------------------------- |
| `escalations:read`     | `GET /escalations`, `GET /escalations/{id}` (the context package, 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`                                                   |
| `escalations:initiate` | `POST /escalations` — start an outbound SMS thread (step 7). Only take this one if you need it.                 |

The key is organization-scoped: it only ever sees its own tenant's sessions, and a cross-tenant call
fails closed as `404`. These scopes are deliberately separate from `escalation_destinations:*` — a
connector key can work conversations but cannot edit the destinations or agents it works for.

## 4. Verify webhook signatures

Flowyte signs every delivery. **Verify before you do any work** — parse nothing, queue nothing, and
above all claim nothing on an unverified body.

| 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, as a decimal string                                               |
| `Flowyte-Delivery`       | The delivery id (also `id` in the body) — your **dedupe key**                   |
| `Flowyte-Event`          | The event type                                                                  |

```js theme={null}
import crypto from "node:crypto";

// rawBody MUST be the exact bytes received. Re-serializing the JSON changes
// whitespace and key order, and the signature will never match again.
function verify(headers, rawBody, secrets) {
  const ts = headers["flowyte-timestamp"];
  const delivery = headers["flowyte-delivery"];
  if (!ts || !delivery) return false;

  // Replay window: reject anything more than 300s away from now.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
  if (!Number.isFinite(skew) || skew > 300) return false;

  const signed = `${delivery}.${ts}.${rawBody}`;
  const sent = [headers["flowyte-signature"], headers["flowyte-signature-prev"]].filter(Boolean);

  // Accept if ANY secret you hold verifies EITHER header. That pairing is what
  // keeps you working through a rotation you have not picked up yet.
  return secrets.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
    return sent.some((sig) => {
      const a = Buffer.from(expected, "utf8");
      const b = Buffer.from(sig, "utf8");
      return a.length === b.length && crypto.timingSafeEqual(a, b); // never ===
    });
  });
}
```

A bad signature or a stale timestamp is a `401` from you, and no work.

<Note>
  **Rotation, concretely.** After `rotate-secret`, Flowyte sends `Flowyte-Signature` (new secret) **and**
  `Flowyte-Signature-Prev` (previous secret) for 24 hours. A connector still holding only the old secret
  keeps verifying, because its secret matches the `-Prev` header. Pick up the new secret inside that
  window; after it, only the new secret verifies.
</Note>

**Answer fast, then work.** Flowyte counts any `2xx` as delivered and gives you **10 seconds** total.
Acknowledge immediately and process asynchronously. A non-`2xx` or a timeout is retried up to **6
attempts** (immediately, then 30s, 1m, 2m, 4m, 8m) and then dead-lettered. Delivery is **at-least-once**,
so **dedupe on `Flowyte-Delivery`**.

<Warning>
  Webhooks are the **doorbell, not the mailbox.** Never reconstruct a conversation from webhook bodies —
  read it from `GET /escalations/{id}/messages`, which is ordered and authoritative.
</Warning>

## 5. Pass the handshake test

The go-live gate. In **Integrations → your harness → Handshake test**, hit **Run test** (or
`POST /escalation-destinations/{id}/test`). Flowyte opens a **sandbox** escalation on a synthetic
conversation, delivers a signed `escalation.test` webhook, and then waits for your connector to complete
the loop:

<Steps>
  <Step title="Verify the signature">
    Exactly as in step 4. Treat `escalation.test` as **identical** to `escalation.requested` — same code
    path, no special case.
  </Step>

  <Step title="Claim">
    `POST /escalations/{id}/claim`. Retry on `409` and `404`: a redelivered webhook can transiently
    conflict, and the session may not be visible for a beat right after it is created. Claiming is
    idempotent for your own key.
  </Step>

  <Step title="Post one message">
    `POST /escalations/{id}/messages` with a `client_message_id` and `text`.
  </Step>

  <Step title="Resolve">
    `POST /escalations/{id}/resolve`.
  </Step>
</Steps>

The response is a step ledger — `{ webhook_delivered, claimed, message_posted, resolved }` — plus a
verdict and, on failure, a hint naming the first step you missed (for example *"Connector claimed the
session but posted no message within 120s. Check the escalations:respond scope on the connector key."*).

<Note>
  **Both a pass and a fail come back as HTTP 200** — read `result`, not the status code. Only a green run
  flips the destination to `active`. A test message reaches no customer and is never billed; your
  connector does not need to know that, and should not branch on `test`.
</Note>

Flowyte ships `tools/mock-connector` (Go, stdlib only) as a working reference for this whole loop, with
a `-fail-step` flag to reproduce each failure hint.

## 6. Route real conversations to it

Set the agent's escalation policy — `PUT /agents/{id}/escalation-policy`, or the **Escalation policy**
page — as an ordered list of rules: *when* to escalate and *where* to route. Conditions are
deterministic (an explicit request for a person, a human request, business hours, channel, a failed tool
or knowledge lookup); routes are `external:<destinationId>`, an email fallback, or a queue. First match
wins.

The policy edits the agent **draft** — [publish](/get-started/draft-vs-published) it so live traffic uses
it.

From then on, the loop is:

1. **`escalation.requested`** arrives. Verify, dedupe, claim.
2. **Claim** returns the context package: the recent transcript turns (each with its spine `seq`), the
   escalation reason, verified identifiers (on SMS, the customer's phone), and completed or failed tool
   actions. It is rebuilt at claim time, so you start current.
3. **Reply** with `POST /escalations/{id}/messages`. `client_message_id` is your idempotency key — a
   replay returns the original receipt instead of double-sending.
4. **Sync** with `GET /escalations/{id}/messages?after_seq=N` and process strictly increasing `seq`.
5. **Finish** with `/resolve`, `/return` (hand back to the Flowyte AI), or `/request-human`.

Claiming starts an **activity-based lease** — about 2 minutes on chat, 4 hours on SMS — and *any* owner
action extends it, including a `/messages` read. Send `/heartbeat` if you go quiet but want to keep the
thread. If the lease expires the thread returns to the AI and you get `409 lease_expired`; re-claim to
continue.

## 7. Start an outbound SMS thread (optional)

If your harness needs to open the conversation rather than wait for one, `POST /escalations` starts a new
SMS thread and sends its first message. The session comes back **already owned by your key** — no claim,
no SLA race — and the customer's replies route to you exactly like any other escalation.

```jsonc theme={null}
POST /escalations                       // scope: escalations:initiate
{
  "agentId": "agt_…",
  "destinationId": "dest_…",
  "to": "+14155551234",
  "text": "Hi Dana — following up on the quote you asked about yesterday.",
  "client_message_id": "6f1c2d3e-4a5b-6789-abcd-ef0123456789"
}
```

Four things decide whether this works, and they catch most first attempts:

* **Consent.** The recipient must have a recorded basis. **An inbound text is consent to text back** — a
  customer who has ever texted the business is already `conversational` and needs nothing extra. A number
  that has never texted in is refused `422 consent_required`.
* **It must be switched on.** Outbound initiation is off by default and enabled per organization by
  Flowyte, on top of the deployment-level Agent Bridge switch. Otherwise `403 harness_outbound_disabled`
  or `503 bridge_disabled`.
* **There is no `from`.** The sending number is derived from the agent's SMS-active number, and the agent
  must have one (`422 no_sms_number`).
* **Send the message only.** The brand identity and the `Msg & data rates may apply. Reply HELP for help,
  STOP to opt out.` block are appended for you on a first text. Writing your own ships it twice.

Quiet hours also apply differently here than to a reply: a first text is business-initiated, so outside
the recipient's legal window it is **refused** `409 quiet_hours` with a `nextOpen` instant to retry
after — never silently queued.

<Warning>
  **`409 send_state_unknown` is not a retry signal.** It means the send outcome is genuinely unknown and
  the message **may already have been delivered**. Replay the **same** `client_message_id` (which resolves
  to the original answer and never sends a second text) or re-read the thread. A fresh id there texts
  somebody twice.
</Warning>

The full contract — every error code, the volume cap, the audited consent bypass, a worked end-to-end
example — is in the [connector reference](/integrations/ai-harness-connector#5-start-an-outbound-sms-thread).

## When it does not work

| Symptom                                            | Cause                                                                                                                                |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Destination will not leave `unverified`            | The handshake never went green. Read the `hint` on the last run — it names the first missed step.                                    |
| `401` on your endpoint, nothing else happens       | Your verification is failing. Almost always a re-serialized body instead of the raw bytes, or clock skew past the 300-second window. |
| `403 insufficient_scope`                           | The connector key is missing the scope for that call. Scopes are per-call, not per-resource.                                         |
| `403 plan_required`                                | The organization is on PAYG. Harness conversations need Starter or higher; only the handshake test is exempt.                        |
| `409 claim_conflict` right after a webhook         | A redelivery or a race. Back off and retry — claiming is idempotent for your key.                                                    |
| `409 destination_unavailable`                      | The AI Harness integration was disconnected, or the destination is no longer active.                                                 |
| `409 lease_expired`                                | You went quiet past the lease. Re-claim, and heartbeat next time.                                                                    |
| Conversation goes quiet and the AI answers         | Your lease expired and the thread returned to the AI, or you called `/return`.                                                       |
| `422 sms_registration_required` on the destination | The organization's 10DLC/TCR campaign is not active yet.                                                                             |

## Next

* [Connector reference](/integrations/ai-harness-connector) — the full contract you keep open while
  building.
* [AI Harnesses](/integrations/ai-harness) — the operator-side view of the same setup.
* [MCP gateway](/get-started/mcp-gateway) — the same surface as agent tools, if your harness speaks MCP
  rather than REST.
