Base URL:
https://api.agentsky.dev
The authoritative, machine-readable contract for everything below is the OpenAPI document at:
https://api.agentsky.dev/v1/openapi.json
If a field or endpoint is not described here, the OpenAPI document is the source of truth — not this prose.
Core model
AgentSky has a small, fixed object model:
| Object | What it is | Lifecycle |
|---|---|---|
| Universe | An isolated tenancy: its own agents, sessions, skills, secrets, and model subscriptions. | Created once; everything else lives inside one. |
| Agent | A reusable configuration: engine (agentType), model (llm), prompt, capabilities, skills, secrets, install steps. | Created, patched, archived, or deleted. Runs zero or more sessions. |
| Session | One running conversation with a working directory. | provisioning → idle / running → terminated. Soft-deleted or archived, never mutated away. |
| Turn | One user message plus the agent's response. | Started by POST /v1/sessions/{id}/messages; boundaries read from the stream. |
| Skill | A versioned package of files an agent loads at boot. | Created once; each revision is a complete snapshot. |
| Routine | A cron-scheduled fire that starts a session or sends a turn. | Created, patched, paused, unpaused, archived. |
Identifiers
Every resource is addressed by a server-minted, prefixed id: agent_… for
agents, sess_… for sessions, skill_… for skills, skr_… for skill
revisions. The prefix is part of the address — a reference with the wrong
prefix answers 404. Agents have no slug: the id is the only address, and
name / displayName are free, non-unique labels you choose. Resources
minted before the id cutover keep their originally issued ids, which continue
to resolve unchanged.
Never derive an address from a name. Capture the id the server returns at
creation and use it verbatim from then on.
Authentication
Every request is authorized with a bearer token that carries an ast_ prefix:
Authorization: Bearer ast_...
A token is either personal (it acts as you everywhere) or scoped to one
universe. Tokens are minted in the AgentSky console (Settings → API
tokens) or by sky auth login — a token can never mint another token.
To act inside a non-personal universe with a personal token, name the universe on the request:
X-Universe: <universe-slug>
A universe-scoped token already knows its universe; sending an X-Universe
that conflicts with it fails with 403 universe_mismatch rather than silently
picking one.
Every token also carries one or more scopes. Each endpoint in the OpenAPI
document declares the scope it requires: read for reads, write for writes,
admin for destructive universe-level actions (deleting or archiving an
agent). Scopes are hierarchical — read ⊂ write ⊂ admin — so a token that can
admin also implies write and read.
Every curl in this guide reads the token from $AGENTSKY_TOKEN, so export it
once before you run anything:
export AGENTSKY_TOKEN=ast_your_token_here
Do not skip that line. An unset variable does not fail loudly — it sends
Authorization: Bearer with an empty token, and the API answers 401, which
reads exactly like a revoked or mistyped credential.
Send GET /v1/whoami first — it returns the resolved identity, universe,
and effective scopes for the token you hold, and is the cheapest way to
confirm a credential before building on it:
curl -sS https://api.agentsky.dev/v1/whoami \
-H "Authorization: Bearer $AGENTSKY_TOKEN"
{
"user": { "id": "u_...", "email": "you@example.com", "name": "You" },
"universe": { "slug": "acme", "name": "Acme", "isPersonal": false },
"scopes": ["read", "write", "admin"],
"auth": "token"
}
Notes:
universe.isPersonalistruefor a personal token. Only personal tokens can create universes (POST /v1/universes).- Secret values and subscription credentials are write-only: the API accepts them but never returns them.
- Rate limit: 100,000 requests per minute per token; exceed it and you get
HTTP
429with coderate_limitedand aRetry-Afterheader. - The human-readable documentation lives at
https://agentsky.dev/docs.
Agents
Create an agent
POST /v1/agents — every field is optional; an empty body {} creates a
default hermes agent. agentType selects the engine:
hermes (default) · claude_code · codex · openclaw · pi · dsh ·
kimi_code · opencode
curl -sS https://api.agentsky.dev/v1/agents \
-H "Authorization: Bearer $AGENTSKY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "support-bot",
"displayName": "Support Bot",
"agentType": "hermes",
"llm": "claude-fable-5",
"prompt": "You are a concise support assistant.",
"capabilities": ["exa.search", "gptimage.generate"]
}'
The 201 response returns the full agent object including its server-minted
id (agent_<cuid>) and version (bumped on every update). You address
the agent by that id from here on; there is no slug, and name is just a
label — it does not have to be unique.
Engine identity is immutable: agentType and llm are chosen at creation
and can never be changed afterward. Changing engines means creating a new
agent.
Full CreateAgent fields:
| Field | Meaning |
|---|---|
name / displayName | Internal label (≤60 chars) and human-facing label (≤60 chars). Free-form, non-unique. |
description | ≤500 chars. |
agentType | Engine, one of the eight values above. Immutable. |
llm | Model id, passed through to the engine; engine↔model mismatches are rejected rather than silently replaced. Immutable. |
reasoningEffort | Pins the engine's native effort level (per-engine vocabulary — the OpenAPI document renders the current matrix); unset means the engine default. |
prompt | The user prompt layer (≤100000 chars). |
capabilities | Built-in tool grants — see the table below. |
instructions | Array of {name: "*.md", content} markdown files injected at startup. |
skills | ≤20 skill attachments: {type: "skill", skillId, version?} (a store reference; version defaults to latest or pins a skr_… revision) or {type: "github", url, ref?, tokenSecretRef?} (a GitHub shortcut). See Skills. |
customInstalls | Array of {command, description?, timeoutSeconds? (≤1800), allowNetwork?} shell steps run before the engine starts. |
mcpServers | MCP server references the engine connects to. |
customData | Array of {id, name, kind, scope?, description?, uri?, config?} data attachments. |
metadata | Your key-merged client metadata, echoed back on reads. |
Capabilities are not LLM tools — they are platform grants surfaced through
the in-pod actl CLI. The full enum:
exa.search · exa.contents · tinyfish.fetch · tinyfish.browser ·
dataforseo.serp · gptimage.generate · rembg.remove-background ·
seedance.generate · mm.i2v · fish-audio.transcribe
Read, update, archive, delete
GET /v1/agents # list agents in the resolved universe
GET /v1/agents/{id} # agent detail (includes prompt, capabilities, metadata, archived)
PATCH /v1/agents/{id} # update displayName / capabilities / metadata / skills / mcpServers / harnessVersion / reasoningEffort
POST /v1/agents/{id}/archive # archive: read-only, sessions keep running, new sessions rejected
DELETE /v1/agents/{id} # delete — only with zero live sessions (409 agent_has_sessions otherwise)
PATCH supports displayName, capabilities, metadata, skills,
mcpServers, harnessVersion, reasoningEffort, and expectedVersion (an
optimistic-concurrency guard: pass the version you last read, and the write
fails with 409 version_conflict if the agent has moved on). A displayName
change is a rename — it also rewrites the prompt identity on every session and
hot-reloads it into live pods; the id never changes.
Archival and deletion both preserve lineage:
- Archive is irreversible: the agent becomes read-only, existing sessions
keep running, and creating a new session on it fails with
409 agent_archived. - Delete requires zero live sessions (
409 agent_has_sessionsotherwise). An agent whose sessions were all soft-deleted is archived instead of removed, so the deleted sessions' transcripts and receipts keep a valid owner. Agent list entries and details carryarchived: boolean.
Prompt
PUT /v1/agents/{id}/prompt # save + hot-reload the user prompt layer
GET /v1/agents/{id}/prompt/versions # version history
PUT returns {version, unchanged, applied, sessions[]}. applied is
live, on-restart, or unreachable — it tells you, per session, whether the
new prompt hot-reloaded or will apply on the next restart.
Secrets
GET /v1/agents/{id}/secrets # declared secret keys + descriptions — never values
PUT /v1/agents/{id}/secrets/{key} # set a declared secret's value (write-only)
DELETE /v1/agents/{id}/secrets/{key} # unset a secret
Secrets are injected into the agent process environment. Values are write-only: you can set and unset them, but the API never echoes them back.
Skills
A skill is a versioned package of files (prompts, scripts, references) that an agent loads at boot. Skills live in the universe's own store; each revision is a complete snapshot, not a diff.
GET /v1/skills # list skills in the resolved universe
POST /v1/skills # create a skill (uploads revision 1)
GET /v1/skills/{skillId} # skill detail (skill_… id, displayName, latestRevisionId)
DELETE /v1/skills/{skillId} # delete a skill and all its revisions
GET /v1/skills/{skillId}/revisions # list revisions, newest first
POST /v1/skills/{skillId}/revisions # create a revision — a complete snapshot
GET /v1/skills/{skillId}/revisions/{revisionId} # revision detail (skr_… id, metadata + file manifest)
DELETE /v1/skills/{skillId}/revisions/{revisionId} # delete one revision
An agent attaches skills through its skills field (create or PATCH, ≤20
entries):
{ "type": "skill", "skillId": "skill_…", "version": "latest" }— a store reference.versiondefaults tolatest(each new session picks up the newest revision) or pins an exactskr_…revision id.{ "type": "github", "url": "https://github.com/…", "ref": "main", "tokenSecretRef": "GH_TOKEN" }— a GitHub shortcut;tokenSecretRefnames one of the agent's declared secrets for private repositories.
Attachment changes take effect at the next session provision or pod boot — live pods keep the skills they booted with.
Model subscriptions
A connected consumer AI subscription (Claude Pro/Max or a ChatGPT plan) can power eligible agents at $0 model usage.
GET /v1/model-subscriptions # list — metadata only, never credentials
PUT /v1/model-subscriptions/{provider} # connect/replace a credential (provider: anthropic | openai)
PATCH /v1/model-subscriptions/{provider} # update useForNewAgents / label
DELETE /v1/model-subscriptions/{provider} # disconnect — affected agents fail until reconnected or switched
A session opts in to account billing by setting modelBilling: "account"
(see Sessions below); the platform meters the turn otherwise.
Sessions
Create a session
POST /v1/sessions — only agent (the agent_… id) is required. By
default the pod is provisioned during the create call so the first turn is
warm; eager: false defers provisioning to the first turn, and an explicit
eager: true makes the create all-or-nothing (a provisioning failure answers
503 and creates nothing).
curl -sS https://api.agentsky.dev/v1/sessions \
-H "Authorization: Bearer $AGENTSKY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent": "agent_cm1234abcd", "title": "First session"}'
A non-empty initial_events list (user.message only, all-or-nothing, ≤50)
starts the first turn in the same call:
curl -sS https://api.agentsky.dev/v1/sessions \
-H "Authorization: Bearer $AGENTSKY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent": "agent_cm1234abcd",
"initial_events": [
{ "type": "user.message", "parts": [ { "index": 0, "type": "text", "text": "Hello" } ] }
]
}'
CreateSession fields: agent (required), eager, title (≤120 chars),
metadata, instructions (≤20 markdown files), initial_events,
environment_id, vault_ids, vcpus, memoryMb, modelBilling
(platform | account; omitted, the platform auto-applies from connected
subscriptions), and budget (see Session budgets).
Machine shape (vcpus / memoryMb) is per-session.
Read, update, archive, delete
GET /v1/sessions # list sessions; ?agent= filters to one agent id
GET /v1/sessions/{id} # detail — status is provisioning | idle | running | terminated
PATCH /v1/sessions/{id} # update title / metadata / modelBilling / budget
POST /v1/sessions/{id}/archive # archive — irreversible; blocks new messages, history stays readable
DELETE /v1/sessions/{id} # soft delete — pod torn down, reads answer 404 afterwards
PATCH updates title, metadata, modelBilling (the change validates
subscription eligibility and restarts the engine in place), and budget. An
archived session is read-only — writes answer 400 session_archived.
The session has a persistent working directory: files you write in one turn survive to the next. It is destroyed when the session is deleted or archived.
Two distinct ways to end a session:
- Archive (
writescope) is irreversible and CMA-shaped: it blocks new messages (400 session_archived) and revival, tears down the pod and stored context, and emitssession.archived. The record and its events stay readable, and the session keeps appearing in lists with statusterminated. Idempotent — re-archiving retries any partial teardown. - Delete (
writescope) is a soft delete: the pod and stored context are torn down, channel bindings, the share link, and playground membership are removed,session.deletedis emitted, and the session disappears from every read path — a follow-up read of the session answers404. The transcript and billing history are retained server-side; they are simply no longer addressable through the API.
Both refuse a running session with 400 session_running. Poll
GET /v1/sessions/{id} until status != "running" first — the stream's
idle event can slightly precede the queryable status flip. A crashed turn can
hold running for at most ~15 minutes (the staleness cutoff), after which
readers treat it as idle.
Never build a session path from an unchecked variable. If the id is empty,
the delete path collapses to /v1/sessions/ — which answers 308 with
Location: /v1/sessions, the collection. A 308 preserves the method,
so any client that follows redirects (many HTTP libraries do by default, and
curl -L does on request) re-sends your delete at a path you never meant to
call. Assert the id first:
: "${SESSION_ID:?set SESSION_ID before deleting}"
curl -sS -X DELETE "https://api.agentsky.dev/v1/sessions/$SESSION_ID" \
-H "Authorization: Bearer $AGENTSKY_TOKEN"
Sharing
POST /v1/sessions/{id}/share # turn sharing on and get the link — idempotent; {"rotate": true} replaces it
DELETE /v1/sessions/{id}/share # turn sharing off — the link stops working immediately
The share link is a read-only guest view of the session. Rotating invalidates the previous link.
Session budgets
A session can carry a hard spend cap:
{ "budget": { "type": "limit", "max_list_cost": { "amount": "500", "currency": "USD" } } }
amount is whole US cents as an integer decimal string — "500" is $5.00.
The budget is attachable only at create; a PATCH can raise it (the new
cap must exceed cost already consumed) or remove it with null — and removal
is one-way: a session that has lost its budget cannot get one back.
When the cap is reached:
- sending a new turn answers
402 budget_reached; - an in-flight turn ends with a
turn.status_idleevent whosestop_reason.typeisbudget_reached.
Sending a turn
POST /v1/sessions/{id}/messages accepts a message and returns 202 with
a bare {} body — the result never comes back in this response. Output
arrives on the event stream (next section).
curl -sS -X POST https://api.agentsky.dev/v1/sessions/$SESSION_ID/messages \
-H "Authorization: Bearer $AGENTSKY_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: turn-7f3a" \
-d '{
"parts": [ { "index": 0, "type": "text", "text": "Summarize this quarter." } ]
}'
The request body requires parts (≥1). The optional Idempotency-Key header
dedupes retries — reuse the same key and a repeated send does not create a
second turn.
A turn is bounded by two turn.status_idle events on the stream: one at the
start (or from history) and one carrying the final stop_reason. The
stop_reason.type is end_turn when the turn finished normally,
interrupted when it was aborted, or budget_reached when the session's
spend cap ended it. Treat any turn.status_idle as the end of the turn,
whatever its stop_reason says — the vocabulary can grow, and a client that
only recognizes end_turn will hang on a value it has never seen.
Interrupt
POST /v1/sessions/{id}/interrupt aborts the in-flight turn:
{ "status": "interrupting" }
status is interrupting when a turn is aborted, or no_turn when nothing
was in flight. The stream then emits turn.interrupted.
Events
AgentSky has two independent ways to receive events, and they answer different questions. Picking the wrong one is the most expensive architectural mistake available here, so decide before you build:
| Session stream (this section) | Channel webhook (see Channel webhooks) | |
|---|---|---|
| Answers | "What is my agent doing inside this session?" | "What happened on my Slack/Telegram/Discord surface?" |
| Transport | Long-lived SSE you hold open, plus a pollable history | Signed HTTP POSTs AgentSky sends to you |
| Needs a public URL | No | Yes (https; http only for localhost) |
| Scope | One session | Your channel connections |
| Replay | GET /v1/sessions/{id}/events, dedupe by event id | Retries at 0s/5s/30s, dedupe by delivery id |
| Use it for | Streaming a turn into your own UI; reasoning and tool traces | Reacting to inbound user messages without holding a socket |
They are not alternatives to each other: a product that renders live agent output and takes messages from Telegram uses both. If you only need to know that a user said something on a connected surface, you do not need a standing SSE connection — take the webhook. (A third receiver, the outbound webhook registry, carries routine lifecycle events.)
The rest of this section is the session stream; channels and webhooks have their own chapters below.
The stream
GET /v1/sessions/{id}/stream is text/event-stream, live-only, and stays
open across turns. Events are {id, type, sessionId, agentId, at, …payload}
(agentId is the owning agent_… id). Event types:
| Event | Meaning |
|---|---|
user.message | A user message. History only — not sent on the live stream. |
agent.message | A completed agent message (messageId, parts, text). |
agent.reasoning | A reasoning delta (part). |
agent.tool_use | A tool invocation (part with call_id, tool_name, args). |
agent.tool_result | A tool result (part with call_id, tool_name, status, result). |
agent.status | A status update (part with level: thinking/working/waiting/idle/done). |
turn.status_idle | A turn boundary. stop_reason.type is end_turn, interrupted, or budget_reached. Never break on a bare idle — finish the turn on the event itself, whatever the stop_reason. |
turn.interrupted | The in-flight turn was aborted. |
session.archived | The session was archived — no further turns; history stays readable. |
session.deleted | Terminal — the session is gone. Close your client. |
error | An error (code, message, retryable). |
The history
GET /v1/sessions/{id}/events returns the session history, oldest-first,
with an opaque numeric cursor. Only user.message, agent.message, and
turn.status_idle are persisted; the live-only types (agent.reasoning,
agent.tool_use, agent.tool_result, agent.status, error) appear on the
stream but not in history. History stays readable after archive.
curl -sS "https://api.agentsky.dev/v1/sessions/$SESSION_ID/events?limit=100" \
-H "Authorization: Bearer $AGENTSKY_TOKEN"
{
"events": [ { "id": "evt_...", "type": "agent.message", "sessionId": "sess_...", "parts": [] } ],
"cursor": "evt_...",
"hasMore": false
}
Query parameters: cursor (resume), limit (≤500), and types[] filters —
types[]=user.message&types[]=agent.message is the transcript view.
There is deliberately no raw pod-log endpoint on this surface (the former session logs route was removed): pod log lines can disclose platform internals, so the events ledger above is the runtime evidence surface.
Reconnect and dedupe
Every event carries a stable id. The reconnect contract is:
- Reopen the stream.
- List events (
GET .../events) from where you left off. - Dedupe by
id— an event you already processed is safe to ignore.
The same id is the dedupe key across the stream and the history, so a
streaming client and a polling fallback can share the same state.
Routines
A routine is scheduled execution: it binds a message (initial_events) and a
target to a cron schedule, and every fire creates a routine run.
GET /v1/routines # list routines
POST /v1/routines # create a routine
GET /v1/routines/{id} # routine detail
PATCH /v1/routines/{id} # update — omit preserves; null clears schedule/budget
POST /v1/routines/{id}/pause # suppress scheduled fires; manual runs still work
POST /v1/routines/{id}/unpause # resume from the NEXT occurrence — missed fires are never backfilled
POST /v1/routines/{id}/archive # terminal; the schedule stops and the routine becomes immutable
POST /v1/routines/{id}/run # fire now, outside the schedule; works while paused
GET /v1/routine-runs # every fire attempt; filter by ?routine=, has_error=, trigger_type=, created_at
GET /v1/routine-runs/{id} # run detail
The target is one of:
{ "type": "new_session", "agent": "agent_…" }— every fire creates a fresh session on that agent (optionally withenvironment_id/vault_ids);{ "type": "session", "session_id": "sess_…" }— every fire sends a turn into one existing session. A busy target produces a failed run withsession_busy_error.
The schedule is { "type": "cron", "expression": "0 9 * * 1", "timezone": "America/Los_Angeles" } — a 5-field POSIX cron expression plus an IANA
timezone — or null for a manual-only routine. An optional per-run budget
uses the same monetary shape as session budgets: on a new_session target it
is copied onto each run's session; on a session target it is a per-fire
delta allowance.
PATCH semantics: omitted fields are preserved, initial_events is replaced
whole, metadata is key-patched, and schedule / budget clear with null.
An archived routine answers 409 routine_archived. Unrecoverable fire errors
auto-pause the routine with the error mirrored in paused_reason.
Each run carries exactly one of session_id (the fire started) or error.
Scheduled fires also emit routine_run.started / routine_run.succeeded /
routine_run.failed to your outbound webhooks; manual
runs do not.
Outbound webhooks
The webhook registry delivers non-channel platform events — today the
routine lifecycle (routine.*, routine_run.*) — to an endpoint you host.
(Channel surfaces have their own registration, POST /v1/channels/webhooks, documented under
Channel webhooks; the delivery contract is identical.)
GET /v1/webhooks # list endpoints
POST /v1/webhooks # register — the whsec_ secret appears only in this response
GET /v1/webhooks/{id} # endpoint detail (url, events, status, failure_count)
PATCH /v1/webhooks/{id} # update url/events, or status:active to re-enable a disabled endpoint
DELETE /v1/webhooks/{id} # delete an endpoint
The body is {url, events} — events is the exact list you will receive
(1–32 entries). Deliveries are signed HTTP POSTs with the same
X-Asteroids-* headers, v1= HMAC-SHA256 signature over
{timestamp}.{rawBody}, and 0s/5s/30s retry ladder described in
Delivery format; sustained failure disables the endpoint
until you re-enable it with PATCH.
Channels
Channels put your agent inside a chat product your users already have — under your bot identity: your Slack app, your BotFather bot, your Discord bot, your WhatsApp business number. AgentSky manages the platform machinery (webhook ingress, socket fleets, rendering, reactions, retries) behind one normalized API.
The object model is four nouns:
| Object | What it is |
|---|---|
| Channel app | Your own bot credentials for one platform. Encrypted at rest, write-only through the API. |
| Connection | One installed conversation surface — a Telegram chat, a Slack channel, a Discord channel, a WhatsApp conversation. |
| Binding | Routes a connection (or one thread of it) to a destination: an AgentSky session, or your own webhook endpoint. |
| Thread id | An opaque {platform}:… string (telegram:12345, slack:C0123:1712.0034) used verbatim for posting and in events. |
1. Register your bot
POST /v1/channels/apps — required credential keys differ per platform:
platform | Required credentials keys |
|---|---|
telegram | bot_token |
discord | bot_token |
slack | bot_token, signing_secret |
whatsapp | access_token, phone_number_id, app_secret |
imessage | api_key, phone_number, webhook_secret — coming soon; answers 501 coming_soon today |
curl -sS -X POST https://api.agentsky.dev/v1/channels/apps \
-H "Authorization: Bearer $AGENTSKY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"platform":"telegram","label":"My bot","credentials":{"bot_token":"123456:ABC..."}}'
Credentials are proven against the platform where an API exists, then stored
write-only — reads return credential_keys (names only), never values. The
response's setup object carries the steps the platform cannot automate for
you: webhook URLs to paste, verify tokens, invite links.
2. Connect a surface
POST /v1/channels/connections. The ceremony depends on the platform, and
this is the branch integrators most often get wrong:
- Link-flow platforms (
telegram,imessage,whatsapp) return aPENDINGconnection with aconnectobject — a deep link, plus a phone number andLINK:code for code flows. Links expire after 15 minutes. The end user claims it (taps Start on Telegram, texts the code on iMessage/WhatsApp); the connection then flipsCONNECTED, the pre-bounddestinationbecomes the default binding, andchannel.connectedfires with yourmetadataechoed back. - Slack and Discord connect synchronously: the call creates a
per-agent channel under your app's identity and returns
CONNECTEDdirectly. There is no pending ceremony, socallback_urldoes not apply.
curl -sS -X POST https://api.agentsky.dev/v1/channels/connections \
-H "Authorization: Bearer $AGENTSKY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"platform":"telegram","app":"app_...","destination":{"session":"sess_..."}}'
For link flows, https://connect.agentsky.dev/connect/{token} renders the
ceremony as a hosted page — safe to hand to an end user mid-conversation, since
no credentials transit it. Pass callback_url and that page redirects the user
back to you with connection_id, status, and your metadata as query
params once the claim lands.
Also available: loopback as a platform on connections — a surface with no
external product attached, useful for testing your routing before a real bot
exists.
3. Route it — bindings
A binding names exactly one of session or webhook as its destination.
That single choice decides how much code you write:
- Session destination — zero code. Inbound messages become agent turns,
replies post back into the thread as rendered markdown, and working markers
(⏳ → ✅) ride the platform's reactions where supported. A refused turn (for
example, out of credits) emits
turn.refused. - Webhook destination — your code. Inbound arrives as a signed
message.receivedevent and you reply through the threads API. No AgentSky session is involved; which agent handles which thread is entirely yours.
# route the whole connection to a session
curl -sS -X POST https://api.agentsky.dev/v1/channels/bindings \
-H "Authorization: Bearer $AGENTSKY_TOKEN" -H "Content-Type: application/json" \
-d '{"connection_id":"...","destination":{"session":"sess_..."},"is_default":true}'
# route ONE thread to your own webhook instead
curl -sS -X POST https://api.agentsky.dev/v1/channels/bindings \
-H "Authorization: Bearer $AGENTSKY_TOKEN" -H "Content-Type: application/json" \
-d '{"connection_id":"...","thread_id":"telegram:app_...:12345","destination":{"webhook":"whk_..."}}'
Precedence: a thread-scoped binding wins over the connection default;
among connection-level bindings, is_default picks the active one.
route_key is a free label for organizing multiple binding rows.
An inbound message on a connected surface with no active binding is not
dropped — it emits channel.unmapped_conversation, so you can bind it or
answer it through the threads API. Handle that event; otherwise those users
are talking into a void that looks, from your side, like nothing happened.
Re-point a surface with PUT /v1/channels/connections/{id}/binding — a
connection keeps a single active session. The switch posts a "You're now
talking to …" notice into the surface and emits channel.rebound.
4. Send into a thread
curl -sS -X POST "https://api.agentsky.dev/v1/channels/threads/telegram:12345/messages" \
-H "Authorization: Bearer $AGENTSKY_TOKEN" -H "Content-Type: application/json" \
-d '{"parts":[{"type":"markdown","text":"done — see **the report**"}],"done":true}'
Parts are text, markdown, or a labeled raw part
({type:"raw", platform, payload}) as the native escape hatch. Markdown
renders natively per platform (Block Kit on Slack, MarkdownV2 on Telegram) and
degrades to plain text elsewhere. done: true clears the working marker on the
message being answered. display_name (≤80 chars) overrides the posting name.
Reactions:
PUT /v1/channels/threads/{id}/messages/{mid}/reactions/{emoji}
DELETE /v1/channels/threads/{id}/messages/{mid}/reactions/{emoji}
Emoji names are platform-neutral.
5. Broadcast, and ask before you branch
POST /v1/channels/deliveries fans out to every thread bound to a
destination; the response carries per-thread results, and failures also emit
delivery.failed.
curl -sS -X POST https://api.agentsky.dev/v1/channels/deliveries \
-H "Authorization: Bearer $AGENTSKY_TOKEN" -H "Content-Type: application/json" \
-d '{"destination":{"session":"sess_..."},"parts":[{"type":"text","text":"nightly report ready"}]}'
Platforms differ, and guessing which one supports what is how integrations break on the second platform. Ask instead:
GET /v1/channels/connections/{id}/capabilities
It returns booleans for threads, reactions, markers, proactive,
markdown, modals, ephemeral, and streaming. Branch on those, not on the
platform name.
Channel webhooks
Register an endpoint once and everything happening on your channels arrives as a signed HTTP POST.
curl -sS -X POST https://api.agentsky.dev/v1/channels/webhooks \
-H "Authorization: Bearer $AGENTSKY_TOKEN" -H "Content-Type: application/json" \
-d '{"url":"https://your.app/hook","events":["message.received","channel.connected","channel.unmapped_conversation","turn.refused","delivery.failed"]}'
The response contains a whsec_ signing secret exactly once, at creation.
Store it then; it is never returned again. The URL must be https (http is
allowed for localhost during development).
events is the exact list you will receive, not a floor — it defaults to
["message.received"] alone, and an event you do not name never arrives. Name
every event you intend to handle, including the ones that only fire when
something is wrong: channel.unmapped_conversation is how you learn a user is
talking to a surface with no binding, and omitting it is indistinguishable from
that never happening.
PATCH /v1/channels/webhooks/{id} re-enables a failure-disabled endpoint
(keeping its secret) and/or re-picks events; DELETE removes one.
Delivery format
Every delivery is a POST with a JSON body {id, type, createdAt, data} and
four headers:
| Header | Meaning |
|---|---|
X-Asteroids-Event | The event type. |
X-Asteroids-Delivery | Unique delivery id — your dedupe key. |
X-Asteroids-Timestamp | Unix seconds, covered by the signature. |
X-Asteroids-Signature | v1=<hex> — HMAC-SHA256 over {timestamp}.{rawBody} with your whsec_ secret. |
Sign over the raw body bytes, before any JSON parsing — re-serializing
changes the bytes and every signature will fail. Keep rawBody a Buffer all
the way into the HMAC: interpolating it into a template string decodes it as
UTF-8, and any byte that is not valid UTF-8 becomes U+FFFD, so a legitimate
delivery fails to verify.
A signature proves the body came from AgentSky. It does not prove it came from AgentSky just now — a captured delivery replayed a year later carries a signature that still checks out. Compare the timestamp against your own clock and reject stale ones; deliveries retry at 0s/5s/30s, so a five-minute tolerance is generous.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
function verify(secret, timestamp, rawBody, signatureHeader) {
const sent = Number(timestamp);
if (!Number.isFinite(sent)) return false;
if (Math.abs(Date.now() / 1000 - sent) > TOLERANCE_SECONDS) return false;
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), Buffer.from(rawBody)]);
const expected = "v1=" + createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(signatureHeader ?? "");
const b = Buffer.from(expected);
// timingSafeEqual throws on a length mismatch — check before comparing.
return a.length === b.length && timingSafeEqual(a, b);
}
In Express, express.json() has already consumed and re-encoded the body by the
time your handler runs — mount express.raw({ type: "application/json" }) on
the webhook route so req.body is still the original Buffer.
Failed deliveries retry at 0s / 5s / 30s. After roughly 20 consecutive
failures the endpoint is disabled and events stop until you re-enable it with
PATCH. Retries reuse the same delivery id, so consume idempotently by
X-Asteroids-Delivery.
Event catalog
| Event | When |
|---|---|
message.received | An inbound message on a webhook-bound conversation — carries connection_id, thread_id, message_id, text, author, and your connection metadata. |
message.reaction | An end user reacted to a message. |
interaction.action | A button or select was tapped (platform spinners are acknowledged for you). |
command.received | A slash command arrived on a connected surface. |
channel.connected | A connect link was claimed — carries your metadata. |
channel.needs_reauth | The platform surface needs re-authorization. |
channel.disconnected | The surface was disconnected. |
channel.unmapped_conversation | A message arrived on a connected surface with no active binding. |
channel.rebound | A connection was re-pointed to a different destination. |
turn.refused | A session-bound turn was refused (for example, out of credits). |
delivery.failed | A proactive delivery could not be posted. |
Developing without a public URL
curl -sS -N https://api.agentsky.dev/v1/channels/events/stream \
-H "Authorization: Bearer $AGENTSKY_TOKEN"
GET /v1/channels/events/stream mirrors your webhook events as SSE — same
payloads, no tunnel required while you build the handler. Note this is a
convenience mirror for development: it does not carry the signature headers, so
verify your HMAC path against a real delivery before shipping.
Message parts
A message is a sequence of typed parts. Every part has an integer index
and a type:
type | Required extra fields |
|---|---|
text | text |
reasoning | text, redacted |
tool_call | call_id, tool_name, args, args_partial |
tool_result | call_id, tool_name, status (ok/error), result |
file | name, media_type, uri/data, size_bytes |
image | media_type, uri/data, alt, width, height |
video | media_type, uri/data, alt, width, height, duration_ms, size_bytes, thumbnail_uri |
status | level (thinking/working/waiting/idle/done), text |
error | code, message, retryable |
Media parts carry either a uri (a signed URL) or inline data, plus
metadata. Indexing is zero-based and defines display order.
Errors
Every error, on every endpoint, has one shape:
{ "error": { "code": "...", "message": "..." } }
The HTTP status distinguishes the class; code is the stable, machine-readable
value. Common ones:
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed JSON or a validation failure. |
| 400 | session_running | Archive/delete refused — a turn is in flight; poll the session until status != "running". |
| 400 | session_archived | A write to an archived (read-only) session. |
| 401 | invalid_token | Missing, malformed, unknown, revoked, or expired token. |
| 402 | insufficient_credits · budget_reached | The spend gate blocked the turn; the session's own budget cap was reached. |
| 403 | insufficient_scope · universe_mismatch · forbidden | Scope too low; X-Universe conflicts with a scoped token; the agent has no API binding. |
| 404 | not_found | Unknown resource — also masks ids you are not allowed to see, so a 404 does not prove absence. |
| 409 | agent_has_sessions · agent_archived · routine_archived · version_conflict | Deleting an agent that still has live sessions; a new session on an archived agent; a write to an archived routine; an optimistic expectedVersion that no longer matches. |
| 422 | invalid_spec | Valid JSON, invalid domain rules (for example an engine↔model mismatch). |
| 429 | rate_limited | 100,000 requests/min per token exceeded — honor Retry-After. |
| 501 | coming_soon | A declared-but-unreleased surface, currently imessage. |
Treat retryable: true on an error event as safe to retry; a bare error
without that flag should be surfaced to a human.
One trap worth coding around. The { error: { code, message } } contract
holds on routes that exist. A request to an unknown path under /v1/
returns the site's HTML 404 page, not JSON — so a client that assumes
"non-2xx implies a JSON error body" throws a parse error and reports something
misleading (usually "invalid JSON") on what is really a typo'd URL. Guard on
the content type:
const res = await fetch(url, { headers });
if (!res.ok) {
const body = res.headers.get("content-type")?.includes("application/json")
? (await res.json()).error // { code, message }
: { code: `http_${res.status}`, message: await res.text() };
throw new Error(`${body.code}: ${body.message}`);
}
The nastier version of the same mistake is dropping the /v1 prefix
entirely. Some of those paths are real pages on the marketing site:
GET https://agentsky.dev/agents answers 200 text/html, so res.ok is
true, the guard above never fires, and the failure surfaces later as a parse
error on what looked like a healthy response. Every API path in this guide
begins with /v1.
End-to-end example
A complete integration in five calls. Copy this whole block into a shell — it
runs end to end, because each step captures what the next one needs. The two
field() reads use python3; any JSON reader does the same job.
export AGENTSKY_TOKEN=ast_your_token_here # required — see Authentication
BASE=https://agentsky.dev
AUTH="Authorization: Bearer $AGENTSKY_TOKEN"
field() { python3 -c 'import json,sys;print(json.load(sys.stdin)["'"$1"'"]["'"$2"'"])'; }
# 1. Confirm identity and scopes.
curl -sS $BASE/v1/whoami -H "$AUTH"
# 2. Create an agent, and keep the id the server minted.
# "name" is just a label; the addressable handle is the returned "id",
# an agent_<cuid> the server generates.
AGENT_ID=$(curl -sS $BASE/v1/agents -H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"demo","agentType":"hermes","prompt":"Be concise."}' | field agent id)
echo "agent: $AGENT_ID"
# 3. Create a session with that id and start the first turn in one call.
SESSION_ID=$(curl -sS $BASE/v1/sessions -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"agent\":\"$AGENT_ID\",\"initial_events\":[{\"type\":\"user.message\",\"parts\":[{\"index\":0,\"type\":\"text\",\"text\":\"Say hello.\"}]}]}" \
| field session id)
echo "session: $SESSION_ID"
# 4. Open the event stream and read until turn.status_idle with stop_reason.type = "end_turn".
curl -sS -N $BASE/v1/sessions/$SESSION_ID/stream -H "$AUTH"
# 5. Tear down — assert the id first, so an empty variable cannot turn this
# delete into a redirect onto the sessions collection. The delete is a
# SOFT delete: a follow-up read of the session answers 404.
: "${SESSION_ID:?session id is empty — step 3 failed}"
curl -sS -X DELETE $BASE/v1/sessions/$SESSION_ID -H "$AUTH"
Verification checklist
To confirm your integration before shipping, walk these in order and check the result of each:
GET /v1/whoamireturns your universe and scopes without error.POST /v1/agentsreturns a201whoseagent.idcarries theagent_prefix — and no slug field anywhere in the response.GET /v1/agents/{id}with that id returns the configuration you created.POST /v1/sessionsreturns a201with asession.idandstatus.POST /v1/sessions/{id}/messagesreturns202with a bare{}body.GET /v1/sessions/{id}/streamdeliversagent.messageand a terminalturn.status_idlewhosestop_reason.typeisend_turn.GET /v1/sessions/{id}/eventsreturns the same events, dedupable byid.POST /v1/sessions/{id}/interruptreturnsinterruptingduring a turn.DELETE /v1/sessions/{id}returns success, the stream emitssession.deleted, and — because the delete is soft — a follow-upGET /v1/sessions/{id}answers404.
Error handling — the paths that only appear when something is wrong:
- A request with a deliberately bad token returns
401 invalid_tokenas JSON in the{ error: { code, message } }shape. - A request to a misspelled
/v1/...path does not crash your client: it returns an HTML 404, and your error path reports the status rather than a JSON parse failure. Check the prefix-less form too —GET https://agentsky.dev/agentsreturns200 text/html, and a client that only branches onres.okwill treat that marketing page as a result. - A personal token plus
X-Universe: <slug>resolves to that universe inwhoami; a conflicting slug on a universe-scoped token returns403 universe_mismatch.
Channels — only if you are integrating a chat surface:
POST /v1/channels/appsaccepts your bot credentials and returnscredential_keyswithout any values, plus asetupobject.POST /v1/channels/connectionsreturnsPENDINGwith aconnect.urlon Telegram, orCONNECTEDdirectly on Slack/Discord.- Claiming the link flips the connection to
CONNECTEDand fireschannel.connectedcarrying themetadatayou passed. GET /v1/channels/connections/{id}/capabilitiesreturns the capability booleans, and your code branches on those rather than on the platform name.POST /v1/channels/threads/{id}/messagesposts into the real surface anddone: trueclears the working marker.- A message on a connected surface with no binding produces
channel.unmapped_conversation— and your handler does something with it.
Webhooks:
POST /v1/channels/webhooksreturns awhsec_secret once; a laterGETdoes not include it.- A real delivery verifies against your HMAC check over
{timestamp}.{rawBody}using the raw bytes, and a tampered body fails. - A captured delivery replayed with its original headers hours later is rejected — a valid signature alone must not be enough.
- Re-delivering the same
X-Asteroids-Deliveryid is a no-op in your system (idempotency actually holds — test it, don't assume it). - A deliberately failing endpoint retries at 0s/5s/30s, and
PATCHre-enables it after it is disabled.
Endpoints at a glance
All 76 operations, grouped, with the scope each one requires. This list is
generated from the live OpenAPI document — if it disagrees with
https://api.agentsky.dev/v1/openapi.json, the spec wins.
# Identity
GET /v1/whoami read
# Universes
GET /v1/universes read
POST /v1/universes write, personal tokens only
GET /v1/universes/{slug} read
# Agents
GET /v1/agents read
POST /v1/agents write
GET /v1/agents/{id} read
PATCH /v1/agents/{id} write
DELETE /v1/agents/{id} admin
POST /v1/agents/{id}/archive admin
PUT /v1/agents/{id}/prompt write
GET /v1/agents/{id}/prompt/versions read
GET /v1/agents/{id}/secrets read
PUT /v1/agents/{id}/secrets/{key} write
DELETE /v1/agents/{id}/secrets/{key} write
# Skills
GET /v1/skills read
POST /v1/skills write
GET /v1/skills/{skillId} read
DELETE /v1/skills/{skillId} write
GET /v1/skills/{skillId}/revisions read
POST /v1/skills/{skillId}/revisions write
GET /v1/skills/{skillId}/revisions/{revisionId} read
DELETE /v1/skills/{skillId}/revisions/{revisionId} write
# Model subscriptions
GET /v1/model-subscriptions read
PUT /v1/model-subscriptions/{provider} write
PATCH /v1/model-subscriptions/{provider} write
DELETE /v1/model-subscriptions/{provider} write
# Sessions
GET /v1/sessions read
POST /v1/sessions write
GET /v1/sessions/{id} read
PATCH /v1/sessions/{id} write
DELETE /v1/sessions/{id} write
POST /v1/sessions/{id}/archive write
POST /v1/sessions/{id}/share write
DELETE /v1/sessions/{id}/share write
POST /v1/sessions/{id}/messages write
GET /v1/sessions/{id}/events read
GET /v1/sessions/{id}/stream read
POST /v1/sessions/{id}/interrupt write
# Routines
GET /v1/routines read
POST /v1/routines write
GET /v1/routines/{id} read
PATCH /v1/routines/{id} write
POST /v1/routines/{id}/pause write
POST /v1/routines/{id}/unpause write
POST /v1/routines/{id}/archive write
POST /v1/routines/{id}/run write
GET /v1/routine-runs read
GET /v1/routine-runs/{id} read
# Outbound webhooks
GET /v1/webhooks read
POST /v1/webhooks write
GET /v1/webhooks/{id} read
PATCH /v1/webhooks/{id} write
DELETE /v1/webhooks/{id} write
# Channels
GET /v1/channels/apps read
POST /v1/channels/apps write
GET /v1/channels/apps/{id} read
PATCH /v1/channels/apps/{id} write
DELETE /v1/channels/apps/{id} write
GET /v1/channels/connections read
POST /v1/channels/connections write
DELETE /v1/channels/connections/{id} write
PUT /v1/channels/connections/{id}/binding write
GET /v1/channels/connections/{id}/capabilities read
GET /v1/channels/bindings read
POST /v1/channels/bindings write
DELETE /v1/channels/bindings/{id} write
GET /v1/channels/webhooks read
POST /v1/channels/webhooks write
PATCH /v1/channels/webhooks/{id} write
DELETE /v1/channels/webhooks/{id} write
POST /v1/channels/threads/{id}/messages write
PUT /v1/channels/threads/{id}/messages/{mid}/reactions/{emoji} write
DELETE /v1/channels/threads/{id}/messages/{mid}/reactions/{emoji} write
POST /v1/channels/deliveries write
GET /v1/channels/events/stream read