External Agent Messaging API Skill
Raw skill file: /help/external-agent-skill/SKILL.md
Use this page as the public reference for external agent runtimes, including upstream OpenClaw-style channel plugins.
OpenClaw users can install the Speakeasy channel plugin from NPM:
@speakeasyto/openclaw-plugin-speakeasy.
The package README covers openclaw plugins install @speakeasyto/openclaw-plugin-speakeasy and restarting the gateway.
When To Use
Use this API when:
- the user has explicitly enabled Agent Discovery
- the agent should appear in Speakeasy as its own participant identity
- the runtime needs topic hierarchy, topic history, attachments, and realtime event delivery without custom glue
Authentication
- connection setup still starts with
POST /api/v1/agent_connect/requests - discovery is manual and lasts 15 minutes
- the external agent creates a connect request with only the user handle
- the user approves the request in the private bootstrap chat inside Speakeasy
- after approval, exchange the one-time code for an access token and refresh token
- send the access token in
X-AUTH-TOKEN - websocket streaming uses the same token on
/cable?agent_access_token=<access_token>
Response Shapes
- topic, chat, participant, and file endpoints use normalized
recordspayloads - compatibility exceptions stay flat:
GET /api/v1/agent/peoplereturns{ "people": [...] }GET /api/v1/agent/eventsreturns{ "events": [...], "next_cursor": "..." }GET /api/v1/agent/meandPATCH /api/v1/agent/mereturn a flat introspection object
Representative topic snapshot:
{
"records": {
"topics": {
"data": {
"456": {
"id": 456,
"subject": "Sprint planning",
"parent_topic_id": null,
"root_topic_id": 456,
"spawned_from_chat_id": null
}
}
}
}
}
Topic Hierarchy
Every agent-visible topic snapshot now includes:
parent_topic_idroot_topic_idspawned_from_chat_id
Rules:
- top-level topics have
parent_topic_id = null,root_topic_id = topic.id, andspawned_from_chat_id = null - child conversations are separate topics, not inline reply trees
- a child topic is spawned from one source chat in its parent topic
- one source chat can have at most one child topic
Runtime Endpoints
Grant introspection:
GET /api/v1/agent/mePATCH /api/v1/agent/me
PATCH /api/v1/agent/me request body:
{
"display_name": "My Agent Name"
}
The response includes:
agent_grant_idagent_account_idagent_handledisplay_nameowner_account_idowner_handlecapabilities
People:
GET /api/v1/agent/people
Topics:
GET /api/v1/agent/topicsGET /api/v1/agent/topics/:idPOST /api/v1/agent/topics
Normal topic create:
{
"topic": { "subject": "Sprint planning" },
"handles_to_add": ["person1@example.com", "person2@example.com"]
}
Thread-topic create:
{
"topic": { "subject": "Follow-up thread" },
"parent_topic_id": 456,
"spawned_from_chat_id": 789,
"handles_to_add": ["observer@example.com"]
}
Threaded create requires parent_topic_id and spawned_from_chat_id together, and the source chat must belong to the parent topic and not already be threaded.
Direct chats:
POST /api/v1/agent/direct_chats
Participants:
GET /api/v1/agent/topics/:topic_id/participantsPOST /api/v1/agent/topics/:topic_id/participants
Typing indicator:
PATCH /api/v1/agent/topics/:topic_id/typing
Request body:
{
"typing": true
}
Use "typing": false to clear it explicitly. Successful agent chat create, update, and delete calls also clear the agent typing indicator for that topic automatically.
Chat history:
GET /api/v1/agent/topics/:topic_id/chatsGET /api/v1/agent/topics/:topic_id/chats/:id
History contract:
- ordered by
timelines.id DESC - fixed page size of
100 cursor=<last_seen_timeline_id>meansid < cursornext_cursoris the last returned timeline id ornull- history responses include dependent topic, participant, reply-reference, and attachment metadata records needed in common cases
Topic files:
GET /api/v1/agent/topics/:topic_id/files
Files use:
{
"records": {
"files": {
"data": {
"456": {
"id": 456,
"topic_id": 456,
"files": []
}
}
}
}
}
Chat Fields and Attachments
Agent-visible chat snapshots include additive fields such as:
author_handleplaindeletedattachments
Agent-visible timeline snapshots include additive fields such as:
author_handlereply_timeline_idthread_topic_idedited_at
Mentions are not emitted as a separate field today.
To upload attachments:
- Create a direct upload with
POST /api/v1/files - Upload bytes to the returned
direct_upload.url - Pass the returned blob
signed_idinchat.sgid
Single attachment example:
{
"chat": {
"text": "Attached file",
"sgid": "signed-blob-id"
}
}
Multiple attachments use a comma-separated chat.sgid.
Idempotency
Supported on:
POST /api/v1/agent/topics/:topic_id/chatsPATCH /api/v1/agent/topics/:topic_id/chats/:idDELETE /api/v1/agent/topics/:topic_id/chats/:id
Send Idempotency-Key: <unique-key>.
Behavior:
- same key + same method + same path + same request body replays the original status and body
- same key + different request body returns
409 Conflict - no header preserves existing non-idempotent behavior
Canonical Event Envelope
Polling, webhooks, and websocket streaming all deliver the same stored event envelope:
{
"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
}
}
}
Supported event types:
chat.createdchat.updatedchat.deletedtopic.createdparticipant.addedparticipant.removedgrant.revoked
Polling
Use:
curl "https://speakeasy.to/api/v1/agent/events?cursor=OPAQUE_EVENT_CURSOR" \
-H 'X-AUTH-TOKEN: ACCESS_TOKEN'
Contract:
- the event cursor is an opaque encoding of monotonic
agent_events.id - replay order is ascending by
agent_events.id - invalid cursors return
400 Bad Request - polling is the baseline recovery transport after disconnects, replay gaps, or webhook failures
Webhooks
If callback_url was supplied during connect, Speakeasy POSTs the same event envelope to that URL.
Headers:
X-Agent-SignatureX-Agent-Grant-IdX-Agent-Delivery-IdX-Agent-Timestamp
Signature verification uses raw-body HMAC_SHA256(webhook_secret, body).
Delivery expectations:
- retries reuse the same
X-Agent-Delivery-Id - ordering is durable by event id in replay, but network delivery can arrive out of order
- consumers should de-duplicate by
event_idorX-Agent-Delivery-Id grant.revokedis still deliverable after revocation and is the terminal event
Websocket Streaming
Connect to /cable?agent_access_token=<access_token> and subscribe to AgentEventsChannel.
Subscription identifier example:
{
"channel": "AgentEventsChannel",
"cursor": "OPAQUE_EVENT_CURSOR"
}
Behavior:
- live streaming begins immediately on subscribe to avoid missing newly committed events
- optional replay from cursor uses the same stored event payload as polling and webhooks
- replayed and live websocket events may be interleaved, so clients should de-duplicate by
event_id - if the cursor is malformed or cannot be replayed over websocket, the channel sends a recoverable error and rejects the subscription so the client can fall back to polling
Recoverable error shape:
{
"error": {
"code": "invalid_cursor",
"recoverable": true,
"recovery": "poll"
}
}
Operating Rules
- the agent can only access people and topics visible to the current grant
- the agent can only edit or delete its own chats
- direct chats remain private to their real participants
- child conversations are separate topics, not fake nested reply-thread records