AgentSky Developer API

The complete third-party integration guide — endpoints, authentication, sessions, skills, routines, channels, webhooks, and the event model.

Base URL:

text
https://api.agentsky.dev

The authoritative, machine-readable contract for everything below is the OpenAPI document at:

text
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:

ObjectWhat it isLifecycle
UniverseAn isolated tenancy: its own agents, sessions, skills, secrets, and model subscriptions.Created once; everything else lives inside one.
AgentA reusable configuration: engine (agentType), model (llm), prompt, capabilities, skills, secrets, install steps.Created, patched, archived, or deleted. Runs zero or more sessions.
SessionOne running conversation with a working directory.provisioning → idle / running → terminated. Soft-deleted or archived, never mutated away.
TurnOne user message plus the agent's response.Started by POST /v1/sessions/{id}/messages; boundaries read from the stream.
SkillA versioned package of files an agent loads at boot.Created once; each revision is a complete snapshot.
RoutineA 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:

http
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:

http
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:

bash
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:

bash
curl -sS https://api.agentsky.dev/v1/whoami \
  -H "Authorization: Bearer $AGENTSKY_TOKEN"
json
{
  "user":    { "id": "u_...", "email": "you@example.com", "name": "You" },
  "universe": { "slug": "acme", "name": "Acme", "isPersonal": false },
  "scopes":  ["read", "write", "admin"],
  "auth":    "token"
}

Notes:

  • universe.isPersonal is true for 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 429 with code rate_limited and a Retry-After header.
  • 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

bash
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:

FieldMeaning
name / displayNameInternal label (≤60 chars) and human-facing label (≤60 chars). Free-form, non-unique.
description≤500 chars.
agentTypeEngine, one of the eight values above. Immutable.
llmModel id, passed through to the engine; engine↔model mismatches are rejected rather than silently replaced. Immutable.
reasoningEffortPins the engine's native effort level (per-engine vocabulary — the OpenAPI document renders the current matrix); unset means the engine default.
promptThe user prompt layer (≤100000 chars).
capabilitiesBuilt-in tool grants — see the table below.
instructionsArray 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.
customInstallsArray of {command, description?, timeoutSeconds? (≤1800), allowNetwork?} shell steps run before the engine starts.
mcpServersMCP server references the engine connects to.
customDataArray of {id, name, kind, scope?, description?, uri?, config?} data attachments.
metadataYour 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

bash
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_sessions otherwise). 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 carry archived: boolean.

Prompt

bash
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

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

bash
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. version defaults to latest (each new session picks up the newest revision) or pins an exact skr_… revision id.
  • { "type": "github", "url": "https://github.com/…", "ref": "main", "tokenSecretRef": "GH_TOKEN" } — a GitHub shortcut; tokenSecretRef names 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.

bash
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).

bash
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:

bash
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

bash
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 (write scope) is irreversible and CMA-shaped: it blocks new messages (400 session_archived) and revival, tears down the pod and stored context, and emits session.archived. The record and its events stay readable, and the session keeps appearing in lists with status terminated. Idempotent — re-archiving retries any partial teardown.
  • Delete (write scope) is a soft delete: the pod and stored context are torn down, channel bindings, the share link, and playground membership are removed, session.deleted is emitted, and the session disappears from every read path — a follow-up read of the session answers 404. 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:

bash
: "${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

bash
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:

json
{ "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_idle event whose stop_reason.type is budget_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).

bash
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:

json
{ "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?"
TransportLong-lived SSE you hold open, plus a pollable historySigned HTTP POSTs AgentSky sends to you
Needs a public URLNoYes (https; http only for localhost)
ScopeOne sessionYour channel connections
ReplayGET /v1/sessions/{id}/events, dedupe by event idRetries at 0s/5s/30s, dedupe by delivery id
Use it forStreaming a turn into your own UI; reasoning and tool tracesReacting 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:

EventMeaning
user.messageA user message. History only — not sent on the live stream.
agent.messageA completed agent message (messageId, parts, text).
agent.reasoningA reasoning delta (part).
agent.tool_useA tool invocation (part with call_id, tool_name, args).
agent.tool_resultA tool result (part with call_id, tool_name, status, result).
agent.statusA status update (part with level: thinking/working/waiting/idle/done).
turn.status_idleA 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.interruptedThe in-flight turn was aborted.
session.archivedThe session was archived — no further turns; history stays readable.
session.deletedTerminal — the session is gone. Close your client.
errorAn 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.

bash
curl -sS "https://api.agentsky.dev/v1/sessions/$SESSION_ID/events?limit=100" \
  -H "Authorization: Bearer $AGENTSKY_TOKEN"
json
{
  "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:

  1. Reopen the stream.
  2. List events (GET .../events) from where you left off.
  3. 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.

bash
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 with environment_id / vault_ids);
  • { "type": "session", "session_id": "sess_…" } — every fire sends a turn into one existing session. A busy target produces a failed run with session_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.)

bash
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:

ObjectWhat it is
Channel appYour own bot credentials for one platform. Encrypted at rest, write-only through the API.
ConnectionOne installed conversation surface — a Telegram chat, a Slack channel, a Discord channel, a WhatsApp conversation.
BindingRoutes a connection (or one thread of it) to a destination: an AgentSky session, or your own webhook endpoint.
Thread idAn 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:

platformRequired credentials keys
telegrambot_token
discordbot_token
slackbot_token, signing_secret
whatsappaccess_token, phone_number_id, app_secret
imessageapi_key, phone_number, webhook_secret — coming soon; answers 501 coming_soon today
bash
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 a PENDING connection with a connect object — a deep link, plus a phone number and LINK: 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 flips CONNECTED, the pre-bound destination becomes the default binding, and channel.connected fires with your metadata echoed back.
  • Slack and Discord connect synchronously: the call creates a per-agent channel under your app's identity and returns CONNECTED directly. There is no pending ceremony, so callback_url does not apply.
bash
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.received event and you reply through the threads API. No AgentSky session is involved; which agent handles which thread is entirely yours.
bash
# 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

bash
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:

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

bash
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:

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

bash
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:

HeaderMeaning
X-Asteroids-EventThe event type.
X-Asteroids-DeliveryUnique delivery id — your dedupe key.
X-Asteroids-TimestampUnix seconds, covered by the signature.
X-Asteroids-Signaturev1=<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.

js
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

EventWhen
message.receivedAn inbound message on a webhook-bound conversation — carries connection_id, thread_id, message_id, text, author, and your connection metadata.
message.reactionAn end user reacted to a message.
interaction.actionA button or select was tapped (platform spinners are acknowledged for you).
command.receivedA slash command arrived on a connected surface.
channel.connectedA connect link was claimed — carries your metadata.
channel.needs_reauthThe platform surface needs re-authorization.
channel.disconnectedThe surface was disconnected.
channel.unmapped_conversationA message arrived on a connected surface with no active binding.
channel.reboundA connection was re-pointed to a different destination.
turn.refusedA session-bound turn was refused (for example, out of credits).
delivery.failedA proactive delivery could not be posted.

Developing without a public URL

bash
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:

typeRequired extra fields
texttext
reasoningtext, redacted
tool_callcall_id, tool_name, args, args_partial
tool_resultcall_id, tool_name, status (ok/error), result
filename, media_type, uri/data, size_bytes
imagemedia_type, uri/data, alt, width, height
videomedia_type, uri/data, alt, width, height, duration_ms, size_bytes, thumbnail_uri
statuslevel (thinking/working/waiting/idle/done), text
errorcode, 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:

json
{ "error": { "code": "...", "message": "..." } }

The HTTP status distinguishes the class; code is the stable, machine-readable value. Common ones:

StatuscodeMeaning
400invalid_requestMalformed JSON or a validation failure.
400session_runningArchive/delete refused — a turn is in flight; poll the session until status != "running".
400session_archivedA write to an archived (read-only) session.
401invalid_tokenMissing, malformed, unknown, revoked, or expired token.
402insufficient_credits · budget_reachedThe spend gate blocked the turn; the session's own budget cap was reached.
403insufficient_scope · universe_mismatch · forbiddenScope too low; X-Universe conflicts with a scoped token; the agent has no API binding.
404not_foundUnknown resource — also masks ids you are not allowed to see, so a 404 does not prove absence.
409agent_has_sessions · agent_archived · routine_archived · version_conflictDeleting 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.
422invalid_specValid JSON, invalid domain rules (for example an engine↔model mismatch).
429rate_limited100,000 requests/min per token exceeded — honor Retry-After.
501coming_soonA 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:

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

bash
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:

  1. GET /v1/whoami returns your universe and scopes without error.
  2. POST /v1/agents returns a 201 whose agent.id carries the agent_ prefix — and no slug field anywhere in the response.
  3. GET /v1/agents/{id} with that id returns the configuration you created.
  4. POST /v1/sessions returns a 201 with a session.id and status.
  5. POST /v1/sessions/{id}/messages returns 202 with a bare {} body.
  6. GET /v1/sessions/{id}/stream delivers agent.message and a terminal turn.status_idle whose stop_reason.type is end_turn.
  7. GET /v1/sessions/{id}/events returns the same events, dedupable by id.
  8. POST /v1/sessions/{id}/interrupt returns interrupting during a turn.
  9. DELETE /v1/sessions/{id} returns success, the stream emits session.deleted, and — because the delete is soft — a follow-up GET /v1/sessions/{id} answers 404.

Error handling — the paths that only appear when something is wrong:

  1. A request with a deliberately bad token returns 401 invalid_token as JSON in the { error: { code, message } } shape.
  2. 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/agents returns 200 text/html, and a client that only branches on res.ok will treat that marketing page as a result.
  3. A personal token plus X-Universe: <slug> resolves to that universe in whoami; a conflicting slug on a universe-scoped token returns 403 universe_mismatch.

Channels — only if you are integrating a chat surface:

  1. POST /v1/channels/apps accepts your bot credentials and returns credential_keys without any values, plus a setup object.
  2. POST /v1/channels/connections returns PENDING with a connect.url on Telegram, or CONNECTED directly on Slack/Discord.
  3. Claiming the link flips the connection to CONNECTED and fires channel.connected carrying the metadata you passed.
  4. GET /v1/channels/connections/{id}/capabilities returns the capability booleans, and your code branches on those rather than on the platform name.
  5. POST /v1/channels/threads/{id}/messages posts into the real surface and done: true clears the working marker.
  6. A message on a connected surface with no binding produces channel.unmapped_conversation — and your handler does something with it.

Webhooks:

  1. POST /v1/channels/webhooks returns a whsec_ secret once; a later GET does not include it.
  2. A real delivery verifies against your HMAC check over {timestamp}.{rawBody} using the raw bytes, and a tampered body fails.
  3. A captured delivery replayed with its original headers hours later is rejected — a valid signature alone must not be enough.
  4. Re-delivering the same X-Asteroids-Delivery id is a no-op in your system (idempotency actually holds — test it, don't assume it).
  5. A deliberately failing endpoint retries at 0s/5s/30s, and PATCH re-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.

text
# 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
這篇文章對你有幫助嗎?