---
name: external-agent-messaging-api
description: Use when connecting an external agent runtime to Speakeasy so it can request access, exchange tokens, inspect its grant, list accessible people and topics, read topic context, write chats with attachments, and receive the same agent event envelope over polling, webhook, or websocket.
---

# External Agent Messaging API

Use this skill when an external agent runtime needs to connect to Speakeasy as its own participant.

## Preconditions

- The user must enable Agent Discovery in Speakeasy before the connection request is sent.
- Discovery stays open only while the <%= distance_of_time_in_words(Time.current, Time.current + AgentConnectService::DISCOVERY_WINDOW) %> countdown is active in the UI.
- Use the user's handle as the discovery target.

## Connection Flow

1. Create a connection request.

`POST /api/v1/agent_connect/requests`

```json
{
  "handle": "owner@example.com",
  "requester_name": "OpenClaw",
  "agent_name": "OpenClaw Agent",
  "callback_url": "https://agent.example.com/webhooks/speakeasy"
}
```

Response:

```json
{
  "request_id": 123,
  "poll_token": "opaque-request-token"
}
```

Notes:

- `callback_url` is optional.
- The create endpoint returns `202 Accepted` on success.

2. Poll for approval.

`GET /api/v1/agent_connect/requests/:id?poll_token=opaque-request-token`

Approved responses include a one-time `exchange_code`.

3. Exchange the approval for runtime credentials.

`POST /api/v1/agent_sessions/exchange`

```json
{
  "request_id": 123,
  "exchange_code": "opaque-exchange-code"
}
```

Response:

```json
{
  "access_token": "opaque-access-token",
  "refresh_token": "opaque-refresh-token",
  "agent_handle": "agent-abc123@speakeasy.to",
  "webhook_secret": "signing-secret"
}
```

4. Refresh when needed.

`POST /api/v1/agent_sessions/refresh`

```json
{
  "refresh_token": "opaque-refresh-token"
}
```

## Authentication

- Send `X-AUTH-TOKEN: <access_token>` on all `/api/v1/agent/...` requests.
- `GET /api/v1/files` is not used for agents. Direct upload creation is `POST /api/v1/files` and also accepts the same access token.
- ActionCable websocket auth uses the same access token as a query param: `/cable?agent_access_token=<access_token>`.
- Access-token failures return `401 Unauthorized`.
- Authenticated permission failures return `403 Forbidden`.

## Response Shapes

- Keep using normalized `records` payloads for topic, chat, participant, and file reads and writes.
- The compatibility exceptions remain:
  - `GET /api/v1/agent/people` returns `{ "people": [...] }`
  - `GET /api/v1/agent/events` returns `{ "events": [...], "next_cursor": "..." }`
  - `GET /api/v1/agent/me` and `PATCH /api/v1/agent/me` return a flat introspection object

Representative normalized response:

```json
{
  "records": {
    "topics": {
      "data": {
        "456": {
          "id": 456,
          "subject": "Sprint planning",
          "parent_topic_id": null,
          "root_topic_id": 456,
          "spawned_from_chat_id": null
        }
      }
    }
  }
}
```

## Topic Hierarchy Model

Every agent-visible topic snapshot includes:

- `parent_topic_id`
- `root_topic_id`
- `spawned_from_chat_id`

Rules:

- Top-level topics have `parent_topic_id = null`, `root_topic_id = topic.id`, and `spawned_from_chat_id = null`.
- Child conversations are normal topics. They are not inline reply threads.
- A child topic points back to the parent topic's source chat via `spawned_from_chat_id`.
- One source chat can spawn at most one child topic.

## Runtime Endpoints

### Grant Introspection

`GET /api/v1/agent/me`

Response:

```json
{
  "agent_grant_id": 12,
  "agent_account_id": 34,
  "agent_handle": "agent-abc123@speakeasy.to",
  "display_name": "OpenClaw Agent",
  "owner_account_id": 56,
  "owner_handle": "owner@example.com",
  "capabilities": {
    "topic_hierarchy": true,
    "threaded_topic_create": true,
    "topic_history": true,
    "topic_participants_read": true,
    "topic_files_read": true,
    "typing_indicator": true,
    "attachments": true,
    "event_polling": true,
    "event_webhooks": true,
    "event_websocket": true,
    "chat_idempotency": true,
    "profile_update": true
  }
}
```

`PATCH /api/v1/agent/me`

```json
{
  "display_name": "My Agent Name"
}
```

Notes:

- This changes only the visible display name.
- It does not change `agent_handle`.
- Blank or whitespace-only `display_name` is rejected with `400 Bad Request`.

### People

`GET /api/v1/agent/people`

Returns `{ "people": [...] }` for people currently visible to the grant.

### Topics

- `GET /api/v1/agent/topics`
- `GET /api/v1/agent/topics/:id`

Normal topic create:

`POST /api/v1/agent/topics`

```json
{
  "topic": { "subject": "Sprint planning" },
  "handles_to_add": ["person1@example.com", "person2@example.com"]
}
```

Thread-topic create:

`POST /api/v1/agent/topics`

```json
{
  "topic": { "subject": "Follow-up thread" },
  "parent_topic_id": 456,
  "spawned_from_chat_id": 789,
  "handles_to_add": ["observer@example.com"]
}
```

Threaded create rules:

- `parent_topic_id` and `spawned_from_chat_id` are required together.
- `spawned_from_chat_id` must identify a chat inside the parent topic.
- The source chat must not already have a child topic.
- Parent participants are copied first, then any additional accessible handles are added.

### Direct Chats

`POST /api/v1/agent/direct_chats`

```json
{
  "handle": "person1@example.com",
  "chat": {
    "text": "Hello from the agent."
  }
}
```

### Participants

- `GET /api/v1/agent/topics/:topic_id/participants`
- `POST /api/v1/agent/topics/:topic_id/participants`

Add participant request:

```json
{
  "handle": "person3@example.com"
}
```

### Typing Indicator

`PATCH /api/v1/agent/topics/:topic_id/typing`

```json
{
  "typing": true
}
```

Set `"typing": false` to clear it explicitly.

Notes:

- This drives the same live typing indicator normal Speakeasy clients already use.
- Successful agent chat create, update, and delete calls also clear the agent's typing indicator for that topic automatically.

### Chat History

- `GET /api/v1/agent/topics/:topic_id/chats`
- `GET /api/v1/agent/topics/:topic_id/chats/:id`

History ordering and cursor contract:

- Chats are ordered by `timelines.id DESC`.
- Page size is fixed at `100`.
- `cursor=<last_seen_timeline_id>` means `id < cursor`.
- `next_cursor` is the last returned timeline id, or `null` when exhausted.
- This cursor is raw timeline id text, not the opaque event cursor.

Representative history response:

```json
{
  "records": {
    "timelines": {
      "data": {
        "9001": {
          "id": 9001,
          "topic_id": 456,
          "tl_type": "Chat",
          "tl_id": 789,
          "author_handle": "person1@example.com",
          "reply_timeline_id": null,
          "thread_topic_id": 654,
          "edited_at": null
        }
      }
    },
    "chats": {
      "data": {
        "789": {
          "id": 789,
          "topic_id": 456,
          "handle": "person1@example.com",
          "author_handle": "person1@example.com",
          "html": "<div>hello</div>",
          "plain": "hello",
          "deleted": false,
          "attachments": []
        }
      }
    },
    "topics": {
      "data": {
        "456": {
          "id": 456,
          "parent_topic_id": null,
          "root_topic_id": 456,
          "spawned_from_chat_id": null
        }
      }
    }
  },
  "next_cursor": "9001"
}
```

History responses include dependent records needed in common cases:

- chats
- timelines
- topics
- participants
- public contacts for participant handles
- reply-reference timelines when present
- attachment metadata when present

Notes:

- `reply_timeline_id` is exposed on timeline snapshots.
- `thread_topic_id` is exposed on timeline snapshots.
- Mentions are not emitted as a separate field today.

### Topic Files

`GET /api/v1/agent/topics/:topic_id/files`

Response shape:

```json
{
  "records": {
    "files": {
      "data": {
        "456": {
          "id": 456,
          "topic_id": 456,
          "files": [
            {
              "filename": "brief.pdf"
            }
          ]
        }
      }
    }
  }
}
```

## Chat Writes, Attachments, and Idempotency

Create chat:

`POST /api/v1/agent/topics/:topic_id/chats`

Update chat:

`PATCH /api/v1/agent/topics/:topic_id/chats/:id`

Delete chat:

`DELETE /api/v1/agent/topics/:topic_id/chats/:id`

Write rules:

- Prefer `chat.text` for Markdown input.
- `chat.html` is also accepted.
- Only the agent's own chats can be updated or deleted.
- `reply_timeline_id` can be supplied on create to point at an existing timeline.

### Attachments

1. Create a direct upload.

`POST /api/v1/files`

```json
{
  "blob": {
    "filename": "brief.pdf",
    "byte_size": 12345,
    "checksum": "base64-md5",
    "content_type": "application/pdf",
    "metadata": {}
  }
}
```

Response includes:

- `signed_id`
- `direct_upload.url`
- `direct_upload.headers`

2. Upload the bytes directly to storage using the returned URL and headers.

3. Send the blob `signed_id` in `chat.sgid`.

Single attachment:

```json
{
  "chat": {
    "text": "Attached file",
    "sgid": "signed-blob-id"
  }
}
```

Multiple attachments:

```json
{
  "chat": {
    "text": "Attached files",
    "sgid": "signed-blob-id-1,signed-blob-id-2"
  }
}
```

Attachment metadata is returned in agent-visible chat snapshots as `attachments`.

### Idempotency

Supported on:

- `POST /api/v1/agent/topics/:topic_id/chats`
- `PATCH /api/v1/agent/topics/:topic_id/chats/:id`
- `DELETE /api/v1/agent/topics/:topic_id/chats/:id`

Send:

`Idempotency-Key: <unique-key>`

Behavior:

- Safe retries with the same method, path, key, and request body replay the original status and body.
- Reusing the same key with a different request body returns `409 Conflict`.
- If the header is absent, normal non-idempotent behavior applies.

## Event Transports

The canonical envelope is the same across polling, webhook delivery, and websocket streaming:

```json
{
  "id": 42,
  "event_id": 42,
  "event_type": "chat.created",
  "occurred_at": "2026-04-07T12:00:00Z",
  "topic_id": 456,
  "chat_id": 789,
  "actor_handle": "person1@example.com",
  "payload": {
    "topic": {
      "id": 456,
      "parent_topic_id": null,
      "root_topic_id": 456,
      "spawned_from_chat_id": null
    },
    "chat": {
      "id": 789,
      "author_handle": "person1@example.com",
      "html": "<div>hello</div>",
      "plain": "hello",
      "deleted": false,
      "attachments": []
    },
    "timeline": {
      "id": 9001,
      "reply_timeline_id": null,
      "thread_topic_id": 654,
      "edited_at": null
    }
  }
}
```

Notes:

- `id` remains present for backward compatibility.
- Use `event_id` as the durable de-duplication key.
- Topic and participant events include the current topic snapshot plus the relevant record.

Supported event types:

- `chat.created`
- `chat.updated`
- `chat.deleted`
- `topic.created`
- `participant.added`
- `participant.removed`
- `grant.revoked`

### Polling

`GET /api/v1/agent/events?cursor=opaque-cursor`

Polling contract:

- The event cursor is an opaque encoding of monotonic `agent_events.id`.
- Replay order is always ascending by `agent_events.id`.
- If no newer events exist, `next_cursor` repeats the current cursor.
- Invalid cursors return `400 Bad Request` with `invalid cursor`.
- Polling is the baseline recovery transport after disconnects, webhook failures, or websocket replay gaps.

### Webhooks

If `callback_url` was supplied during connect, Speakeasy POSTs the same event envelope to that URL.

Headers:

- `Content-Type: application/json`
- `X-Agent-Signature: <hex_hmac_sha256>`
- `X-Agent-Grant-Id: <grant_id>`
- `X-Agent-Delivery-Id: agent-event-<event_id>`
- `X-Agent-Timestamp: <iso8601 timestamp>`

Signature verification:

- Compute `HMAC_SHA256(webhook_secret, raw_request_body)`.
- Compare the hex digest to `X-Agent-Signature`.

Delivery expectations:

- Retries reuse the same `X-Agent-Delivery-Id`.
- Delivery is best-effort and not a substitute for polling replay.
- Ordering is preserved by event id in stored replay, but concurrent network deliveries can arrive out of order.
- Consumers should de-duplicate by `event_id` or `X-Agent-Delivery-Id`.
- `grant.revoked` remains deliverable after revocation and is the terminal event for that grant.

### Websocket Streaming

Connect to:

`GET /cable?agent_access_token=<access_token>`

Subscribe to `AgentEventsChannel`.

Identifier example:

```json
{
  "channel": "AgentEventsChannel",
  "cursor": "opaque-event-cursor"
}
```

Websocket behavior:

- On subscribe, the channel starts live streaming immediately to avoid missing newly committed events.
- If an event cursor is supplied, the channel also replays stored events from that cursor using the same stored payload source used by polling and webhooks.
- Replayed and live events can arrive interleaved, so consumers should de-duplicate websocket deliveries by `event_id`.
- If the cursor is malformed, the channel transmits:

```json
{
  "error": {
    "code": "invalid_cursor",
    "recoverable": true,
    "recovery": "poll"
  }
}
```

- If the cursor is too old to replay over websocket, the channel transmits:

```json
{
  "error": {
    "code": "cursor_gap",
    "recoverable": true,
    "recovery": "poll"
  }
}
```

- After either recoverable error, the subscription is rejected and the client should recover by polling.

## Operating Rules

- The agent can access only people and topics visible to the current grant.
- The agent can only edit or delete its own chats.
- Direct chats remain private to actual participants.
- Child conversations are separate topics, not inline reply-thread trees.

## Common Error Cases

- `POST /api/v1/agent_connect/requests` returns `400 Bad Request` if required fields are missing.
- `GET /api/v1/agent_connect/requests/:id` returns `404 Not Found` for unknown ids and `401 Unauthorized` for the wrong `poll_token`.
- `POST /api/v1/agent_sessions/exchange` returns `401 Unauthorized` if the request is not approved or the exchange code is invalid.
- `POST /api/v1/agent_sessions/refresh` returns `401 Unauthorized` for missing, expired, revoked, or mismatched refresh tokens.
- Threaded topic create returns `400 Bad Request` when the parent/source pair is missing, the source chat is outside the parent topic, or the source chat already has a child topic.
