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

> ## Agent Instructions
> The Relay API base URL is https://api.relayapp.im. Never use workers.dev origins.
> The contract is raw HTTPS and JSON at https://api.relayapp.im. The one optional published package is @relaymessenger/cli. Import nothing else.
> One send is one message. Mint a message_id (msg_ plus a lowercase Crockford ULID) before sending; it is the message's canonical id and the send's idempotency key, so a retry with the same id replays the stored message.
> Message content is immutable. There is no edit, unsend, or delete route, and no message versions or tombstones.
> A reply is a pointer: reply_to is { message_id, part_id? } and the client draws the quote from the target.
> Verify webhooks with the Standard Webhooks signature over the exact raw request body before parsing it.
> Webhooks and GET /v1/events read the same durable log and can run at once. The pull is plain: after is the last sequence you processed, and nothing is acknowledged.
> An agent in a group is an ordinary member. It receives every message from the sequence it joined at; there are no invocations and no invocation_id.

# Cloudflare Agents

> Add Relay as a channel to a Cloudflare Think agent, or deploy a Relay agent on the Agents SDK with one instance per conversation.

There are two ways to answer Relay conversations on Cloudflare, and which one
you want depends on whether you already have an agent.

| You have                                                                                                          | Do this                                                                                        |
| ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| An agent on [Think](https://developers.cloudflare.com/agents/harnesses/think/), Cloudflare's chat agent framework | [Add Relay as a channel](#add-relay-as-a-channel-to-a-think-agent), next to Telegram and Slack |
| No agent yet, or an agent whose only job is Relay                                                                 | [Deploy the starter](#deploy-the-starter) on the Agents SDK                                    |

Prerequisites for both:

* **An agent and its Agent Token.** See [Create and connect an agent](/guides/your-agent).
* **A Cloudflare account.** Both paths deploy with `wrangler`.
* **Node 20 or newer.**

## Add Relay as a channel to a Think agent

Think builds a channel by wrapping a Chat SDK adapter in `messengerChannel()`.
That is how its own Telegram channel is built. Relay publishes a Chat SDK
adapter, so Relay is one more channel: your agent, its tools, and its memory
stay exactly as they are.

<Steps>
  <Step title="Install the adapter">
    ```bash theme={null}
    npm install @cloudflare/think @relaymessenger/chat-sdk-adapter agents ai chat
    ```

    On Workers, the adapter must be `0.2.1` or newer.
  </Step>

  <Step title="Declare the channel">
    The channel id names the route. Called `relay`, it serves
    `POST /messengers/relay/webhook`.

    ```ts src/index.ts theme={null}
    import { Think, messengerChannel } from "@cloudflare/think";
    import { chatSdkMessenger } from "@cloudflare/think/messengers";
    import {
      createRelayAdapter,
      verifyWebhookSignature,
    } from "@relaymessenger/chat-sdk-adapter";

    function relayWebhookVerifier(secret: string) {
      return async (request: Request): Promise<boolean> => {
        try {
          await verifyWebhookSignature({
            secret,
            payload: await request.text(),
            headers: {
              "webhook-id": request.headers.get("webhook-id"),
              "webhook-timestamp": request.headers.get("webhook-timestamp"),
              "webhook-signature": request.headers.get("webhook-signature"),
            },
          });
          return true;
        } catch {
          return false;
        }
      };
    }

    export class MyAgent extends Think<Env> {
      configureChannels() {
        return {
          relay: messengerChannel(
            chatSdkMessenger({
              adapter: createRelayAdapter({
                token: this.env.RELAY_AGENT_TOKEN,
                webhookSecret: this.env.RELAY_WEBHOOK_SECRET,
              }),
              provider: "relay",
              userName: "Relay Agent",
              verifyWebhook: relayWebhookVerifier(this.env.RELAY_WEBHOOK_SECRET),
            }),
          ),
        };
      }
    }
    ```
  </Step>

  <Step title="Set the secrets and deploy">
    ```bash theme={null}
    npx wrangler secret put RELAY_AGENT_TOKEN
    npx wrangler deploy
    ```
  </Step>

  <Step title="Register the webhook">
    ```bash theme={null}
    curl -sS -X POST "https://api.relayapp.im/v1/webhooks" \
      -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://<your-worker>.workers.dev/messengers/relay/webhook",
        "events": ["message.received"]
      }'
    ```

    Save `signing_secret` from the response, then set it:

    ```bash theme={null}
    npx wrangler secret put RELAY_WEBHOOK_SECRET
    ```
  </Step>

  <Step title="Message your agent">
    Open Relay, find your agent, and send it a message. It replies.

    <Check>
      An unsigned or forged delivery to the webhook route returns `401` before
      Think parses anything.
    </Check>
  </Step>
</Steps>

Think owns the parts a channel needs, so you write none of them.

| Think handles                 | How                                                                                                                            |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| The webhook route             | The channel id becomes `/messengers/<id>/webhook`                                                                              |
| Refusing forged deliveries    | `verifyWebhook` runs before any parsing. Every custom messenger must supply one, or opt out explicitly                         |
| One conversation per thread   | Think fans out to a sub-agent per Chat SDK thread, and a Relay thread is one conversation                                      |
| Surviving a restart mid-reply | The reply runs in a durable fiber, which Think replays or closes with its interruption message rather than risking a duplicate |
| Bursts                        | The Chat SDK debounces a run of messages into one turn                                                                         |

Two Relay rules shape what arrives on the other end. One send is one message, so
the adapter buffers a streamed turn and posts the finished text once instead of
editing a draft bubble into place. In a group, the agent is an ordinary member:
it receives every message from the sequence it joined at, and each reply is an
ordinary send.

<Note>
  A runnable version of this is
  [`examples/think-channel`](https://github.com/relaymessenger/relay-agent-starter/tree/main/examples/think-channel)
  in the starter repository.
</Note>

## Deploy the starter

Deploy [relay-agent-starter](https://github.com/relaymessenger/relay-agent-starter)
to answer Relay conversations from a [Cloudflare Agent](https://developers.cloudflare.com/agents/).
Each conversation gets its own agent instance, its own SQLite ledger, and its
own alarm, so a reply survives an evicted isolate. Replace one function with
your model call and it is your agent.

<Info>
  The starter is developed in the public
  [`relaymessenger/relay-agent-starter`](https://github.com/relaymessenger/relay-agent-starter)
  repository. It depends on Cloudflare's [`agents`](https://www.npmjs.com/package/agents)
  package and nothing of Relay's own beyond the HTTPS API documented on this site.
</Info>

<Steps>
  <Step title="Scaffold or deploy the starter">
    Use the one-click deploy:

    [Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/relaymessenger/relay-agent-starter)

    Or scaffold locally, then deploy:

    ```bash theme={null}
    npm create cloudflare@latest -- --template relaymessenger/relay-agent-starter
    npm install
    npx wrangler deploy
    ```

    Note the deployed URL. It looks like
    `https://relay-agent.<your-subdomain>.workers.dev`.

    <Warning>
      Never add `--accept-defaults` to the scaffold command. That flag ignores
      `--template`, scaffolds a hello-world Worker, and still prints success.
    </Warning>
  </Step>

  <Step title="Set the Agent Token">
    The deploy button prompts for it. Locally:

    ```bash theme={null}
    npx wrangler secret put RELAY_AGENT_TOKEN
    ```
  </Step>

  <Step title="Register the webhook">
    Point Relay at `/webhooks/relay` on the deployed Worker:

    ```bash theme={null}
    curl -sS -X POST "https://api.relayapp.im/v1/webhooks" \
      -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://relay-agent.<your-subdomain>.workers.dev/webhooks/relay",
        "events": ["message.received"]
      }'
    ```

    Save `signing_secret` from the response. Relay never returns it again.
  </Step>

  <Step title="Set the signing secret and redeploy">
    ```bash theme={null}
    npx wrangler secret put RELAY_WEBHOOK_SECRET
    npx wrangler deploy
    ```
  </Step>

  <Step title="Message your agent">
    Open Relay, find your agent, and send it a message. It replies.

    <Check>
      `GET /healthz` on the Worker returns `{"ok":true}` when it is up.
    </Check>
  </Step>
</Steps>

## What it uses from the Agents SDK

The starter is a normal Cloudflare Agents project. `RelayConversationAgent`
extends the SDK's `Agent` class, and each row below is a stock part of the
`agents` package doing the job it was built for.

| SDK surface                               | Where            | What it does here                                       |
| ----------------------------------------- | ---------------- | ------------------------------------------------------- |
| `class ... extends Agent<Env, State>`     | `src/agent.ts`   | One instance per conversation                           |
| `initialState`, `setState()`              | `src/agent.ts`   | Last event, last reply, cached handle                   |
| `this.sql`                                | `src/agent.ts`   | The turn ledger, in the instance's own SQLite           |
| `this.schedule(delay, callback, payload)` | `src/agent.ts`   | The alarm that drives the reply                         |
| `onStart()`                               | `src/agent.ts`   | Creates tables, sweeps old rows, re-arms stranded turns |
| `onRequest(request)`                      | `src/agent.ts`   | Receives the verified event from the Worker             |
| `getAgentByName(namespace, name)`         | `src/index.ts`   | Routes each conversation to its own instance            |
| `new_sqlite_classes` migration            | `wrangler.jsonc` | Gives the class its SQLite storage                      |

The instance name is a digest of `chat_id`, so every event for one
thread reaches one object and one ledger.

<Warning>
  The starter serves explicit routes only, with no `routeAgentRequest`
  fallthrough. That helper's default `/agents/<binding>/<name>` shape includes an
  unauthenticated WebSocket that syncs agent state to any caller. Keep the
  fallthrough out unless you are authenticating it yourself.
</Warning>

## What the starter handles for you

Each row is a contract from these docs that the starter implements.

| Contract                                                                       | Starter behavior                                                                                                                                                                                                            |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Signature verification](/guides/webhooks)                                     | Standard Webhooks HMAC over the exact raw body, checked before anything parses it. An unsigned, tampered, or stale request gets `401`                                                                                       |
| [At-least-once delivery](/guides/delivery-model)                               | `202` is returned only after the ledger row and the alarm both exist. Anything earlier returns `5xx`, so Relay redelivers rather than dropping the reply                                                                    |
| One reply per user turn                                                        | Each send arrives as one `message.received` event. Events that land inside a two second window collect into a turn, and the turn gets one reply                                                                             |
| [Idempotent sends](/guides/sending-messages)                                   | The reply's `message_id` carries a digest of the reply itself, so a retry writing the same words replays the stored message and a retry writing different words mints a new id instead of taking `409 idempotency_conflict` |
| [Read receipts](/guides/read-receipts) and [typing](/guides/typing-indicators) | The message is marked read and typing starts before model work, so the sender sees Read while they wait                                                                                                                     |
| Surviving eviction                                                             | Replies are armed with `schedule()`, which sets a Durable Object alarm and wakes an evicted instance on its own. `queue()` would strand the reply                                                                           |
| Retries                                                                        | A failed turn backs off to a ceiling of fifteen minutes across six attempts. A `4xx` from Relay is terminal and is not retried                                                                                              |
| [Event compatibility](/reference/events)                                       | Any event that is not a user `message.received` is acknowledged and dropped, so a new event type never breaks the Worker                                                                                                    |

<Note>
  The ledger stores identifiers only. Message text is re-read from Relay at reply
  time rather than parked in agent storage.
</Note>

## Write your agent

Everything you change lives in one function, `generateReply` at the bottom of
`src/agent.ts`:

```ts src/agent.ts theme={null}
async generateReply(text: string, mediaCount: number, handle: string): Promise<string> {
  return `@${handle} here. You said: ${text}`;
}
```

`text` is the whole user turn, with the messages of a burst joined back in
order. `mediaCount` is how many media parts came with it, for a model that
cannot open them yet.

Call any model from there. A Workers AI example is commented directly above the
function and needs no extra secrets: add the `ai` binding to `wrangler.jsonc`,
uncomment `AI` in `src/env.ts`, and swap the return.

<Tip>
  Keep the call inside the Worker CPU budget. Longer work belongs behind another
  `schedule()` call, which is the same alarm mechanism the reply already uses.
</Tip>

## Secrets

| Secret                 | Where it comes from                                                     |
| ---------------------- | ----------------------------------------------------------------------- |
| `RELAY_AGENT_TOKEN`    | The Agent Token from the Relay app. Relay shows it once                 |
| `RELAY_WEBHOOK_SECRET` | `signing_secret` from `POST /v1/webhooks`. Relay never returns it again |

`RELAY_API_ORIGIN` is a plain var, already set to `https://api.relayapp.im`.

## Running it as a plain Worker

The starter needs the Agents SDK, because the ledger, the state, and the alarm
are the SDK's. If you want a Relay backend with none of that, answer the webhook
from any HTTPS route you already run and send replies with `POST /v1/messages`.
[Webhooks](/guides/webhooks) has the verification steps. You then own the
redelivery, idempotency, and turn coalescing this page's table lists.

## Next steps

* [Webhooks](/guides/webhooks) for registration, verification, and secret rotation
* [Sending messages](/guides/sending-messages) for every part type beyond text
* [Group conversations](/guides/group-conversations) for how an agent takes part in a group
* [Integrations](/integrations) for every way to connect an agent
