The HTTP API¶
Everything the console does, it does through this API. There is no private surface: the same endpoints are available to you.
The interactive reference is generated from the code and served by the
deployment itself at /docs, with the schema at
/api/v1/openapi.json. Both are on in development and off in production —
ENVIRONMENT decides, so a production deployment does not publish its own
route list.
Authenticating¶
Three ways in, for three different callers.
| Header | For | |
|---|---|---|
| Organization API key | Authorization: Bearer aos_… |
A script, an HTTP client such as Postman, or an MCP client. Acts as the member who issued it, narrowed to the permissions it was issued with |
| JWT | Authorization: Bearer <access token> |
A person, or something acting as one. Short-lived, refreshed with a refresh token |
| Session cookie | set by the console | The browser only — the token is HttpOnly and never reaches JavaScript |
Organization API keys¶
A key is issued by a member, in one organization, from Settings → API
(or POST /api/v1/api-keys from a signed-in session). It carries that member's
authority, narrowed twice:
- To the permissions it was issued with. Pick a preset — Read-only, Knowledge ingest, Full access — or tick permissions from the catalog. You can only grant what you hold.
- To what the issuer may do now. Every request re-reads the issuer's membership, so demoting them narrows each of their keys at once. Removing them from the organization, or their leaving it, revokes every key they issued and every app they connected - for good, so inviting them back wakes none of it. A resource grant widens what a person may do to one row; it never widens a key past its permissions.
The key is shown once, in the response that creates it. Only a SHA-256 of it
is stored, and it never appears in a log line, an audit entry or an error body.
Lists show its prefix (aos_1a2b3c4d), which is also how an audit entry names
the key that acted — every entry recorded during a key-authenticated request
carries via_api_key in its details. A key can have an expiry, and revoking it
(DELETE /api/v1/api-keys/{id}) takes effect on its next request.
A key is not its issuer's person. A run it starts is owned, budgeted and audited as the issuer, but it reaches none of their personal state: no memory, no search of their past conversations, no sites they kept signed in for the browser, and no user-scoped sandbox workspace. Whoever holds the key is a script, not the person, so the run answers as it would on a public widget.
Read-only connection discovery is also public: GET /sandbox-connections,
GET /sandbox-connections/runtimes and GET /sandbox-connections/{id}/policy
require connections:view; GET /mcp-connections requires mcp:manage.
Organization and key scopes still apply. Connection writes and credential
operations remain session-only.
Two refusals are worth knowing:
403"API keys are not accepted on this endpoint" — keys are accepted on the public API only: agents, runs and approvals, knowledge bases and RAG, skills, context files, projects, apps, the ML services,/me/permissions, and an organization's members, invitations, groups and settings. The console's own routes, your account, leaving or handing over an organization, and key management itself stay session-only, so a leaked key cannot mint its successor.401"Invalid, expired or revoked API key" — the same sentence for each case, so a wrong key learns nothing about which keys exist.
Each key is also rate limited on its own, RATE_LIMIT_API_KEY_PER_MINUTE
requests a minute (600 by default), and a run or an ML call it makes counts
against those limits for the key rather than for its issuer. Both checks run
before the key is looked up, on a request and on a socket handshake: a key is
counted by a digest of the whole token - never by its prefix, which listings show -
and an address that has sent RATE_LIMIT_AUTH_PER_MINUTE keys that do not exist is
answered 429 without a lookup until its minute is over.
Without a browser¶
A script can get its own key with no console open. Sign in with a form body (the
OAuth2 password grant, so JSON answers 422), pick the organization, issue the
key with the session token, and revoke it the same way when it is done:
SESSION=$(curl -s -X POST "$BASE/api/v1/auth/login" \
-d "username=$EMAIL" --data-urlencode "password=$PASSWORD" | jq -r .access_token)
ORG_ID=$(curl -s "$BASE/api/v1/orgs" -H "Authorization: Bearer $SESSION" \
| jq -r '[.items[] | select(.is_personal)][0].id')
curl -s -X POST "$BASE/api/v1/api-keys" \
-H "Authorization: Bearer $SESSION" -H "X-Organization-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d '{"name": "nightly import", "scopes": ["collections:view", "collections:edit"]}'
# -> {"id": "...", "key": "aos_...", "prefix": "aos_1a2b3c4d", ...}
curl -s -X DELETE "$BASE/api/v1/api-keys/$KEY_ID" \
-H "Authorization: Bearer $SESSION" -H "X-Organization-Id: $ORG_ID"
GET /api/v1/api-keys/scopes lists what the signed-in member may put on a key,
and the presets over it. A deployment that signs people in only through SSO has
no password to send; there a member issues the key in the console.
examples/public-api.sh is the whole round trip as
one script: it issues a read-only key, lists agents and runs with it, shows the
403 for a route outside the key's permissions, runs an agent and reads the run
back when AGENT_ID is set, and revokes the key on exit.
Sessions and revocation¶
A JWT access token is bound to the session its sign-in opened — the session's id
travels inside the token. Signing out everywhere (DELETE /sessions) deactivates
those sessions, and a bound token is then refused on its next use rather than
living out its few remaining minutes. That reaches an open chat WebSocket too: the
next frame on a revoked session closes the socket, not only the next HTTP request.
Refreshing does not start a new session — the refresh token rotates in place and the access token keeps naming the same one — so a long-lived connection is not cut off by a routine refresh.
The organization header¶
An API key acts in its own organization, and needs no header. Sending
X-Organization-Id with a key is allowed only when it names that same
organization; naming another one answers 400 with
details.header = "X-Organization-Id" rather than switching tenant.
A session token takes the tenant from X-Organization-Id. A caller who
belongs to three organizations is a different principal in each, with a
different role and different grants, so send the header on every request. When
it is absent the request falls back to the deployment's default organization
if the caller belongs to it, then to their personal organization, then to
their only one — a script that forgets the header acts there, with whatever
agents, grants and budget that organization has, and gets no error. A caller who
belongs to several and has neither gets a 400 with
details.header = "X-Organization-Id" rather than a guess
(where a new account starts). Send
the wrong one and you get a refusal that looks exactly like the resource not
existing — deliberately, so ids stay unprobeable.
Running an agent¶
curl -X POST "$BASE/api/v1/agents/$AGENT_ID/run" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "How do I rotate a provider key?"}'
The response carries the run id, the output, the status, the tokens and the cost.
environment_id in the body picks which environment answers.
"draft": true runs the unpublished draft instead, as the Builder's test panel
does: a test run, for a key whose issuer may edit the agent, refused with the
reason when the draft would not publish, and never with Prefer: respond-async.
A key also reaches an agent's routines - GET /api/v1/triggers and
GET, POST, PATCH and DELETE under /api/v1/agents/{agent_id}/triggers,
plus POST .../{trigger_id}/run to fire one now - and the organization's models
at GET /api/v1/providers/model-profiles. Three routes act on the issuer
themselves: GET /api/v1/me/mcp-connections lists the services they connected
with their own account, PUT /api/v1/users/me/locale sets the console's
language for them on every device, and PATCH /api/v1/users/me/agent-context
saves their role (job_title) and what they work on (about), which every
agent is told. Each needs a key issued for agents:run.
POST /api/v1/agents creates an agent from the spec in its body, and keeps an
empty field empty. With "from_starter": true it fills what the spec leaves out -
the instructions, skills, the platform's own tools and the budget - from the
AgenticOS starter agent, as New agent in the console does.
A run continues a conversation. A call without conversation_id opens a new
conversation for the key's issuer and returns its id as conversation_id; send
it with the next call and the agent reads what was said before - the recent
turns, or the summary of older ones and what came after. A workspace shared per
conversation, the default, is that conversation's. An unknown id, or somebody
else's, answers 404; one of the issuer's own conversations from the console or a
channel answers 400 - a key that may run agents was not given the person's chats.
So does a parked run: a key resumes or answers only a run a key started, and
one the person started answers 403, since continuing it acts as them.
The conversation is filed in the issuer's chat list,
labelled API and hidden until they switch such conversations on there.
GET /api/v1/conversations/{id}/transcript reads one of the issuer's
conversations turn by turn, with the tools each turn called and what it cost,
for a key holding runs:view.
An API caller cannot route around governance
This endpoint goes through the same runner as the console, Slack and the widget. The run is recorded, the budget is checked before the model request, the approval gate applies, and the cost lands in the same dashboard.
That is the point of one runner, and it is why there is no "fast path" that skips it.
Without holding the connection¶
A run of minutes - a browse, a long tool chain - holds the request open the
whole time, and a proxy's read timeout can cut it off while the run goes on.
Send Prefer: respond-async and the call answers 202 at once instead, with a
run request to poll:
curl -X POST "$BASE/api/v1/agents/$AGENT_ID/run" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: respond-async" \
-H "Idempotency-Key: ticket-4711" \
-d '{"prompt": "Summarise this quarter for the board"}'
# 202, Location: /api/v1/run-requests/{id}, Preference-Applied: respond-async
curl "$BASE/api/v1/run-requests/$REQUEST_ID" -H "Authorization: Bearer $AGENTICOS_KEY"
The request reads queued, running, then completed with result - the
same body the synchronous call answers - or failed with error. Its
conversation_id is known from the first answer, so a follow-up can be sent
before the run ends. A worker runs it through the same runner, budget and
approvals as every other surface.
What can be checked at once is checked at once: an unknown conversation or
attachment is a 404 on the POST, not a failure to poll for. The key's
authority is checked again when the run starts, so a key revoked while its run
waited does not run it. Only the person who asked, or a key of theirs, can read
a run request; anyone else's answers 404. An asynchronous run gets one
attempt - a run that failed is recorded with what it spent, and is not retried
into spending it twice.
The route carries a rate limit rather than a permission gate. Permission is decided inside the service, against that specific agent's grants — a role gate on a per-resource route cannot see them.
PATCH /api/v1/agents/{id}/metadata sets an agent's categories and tags
with a body like {"categories": [...], "tags": [...]}, where an empty list
clears that facet. Values are normalized — trimmed, whitespace-collapsed,
case-folded and de-duplicated — and bounded: at most 10 categories and 20 tags,
each at most 32 characters, a longer item answering 422. Like the run route it
carries no role gate; the grant-aware agents:edit check inside the service
decides, so a viewer holding an edit grant on one agent may retag it.
GET /api/v1/agents filters that catalog through repeatable category and tag
query parameters: values OR within a facet and AND across facets, matched
case-insensitively (a query value folds the way a stored one does, and a blank
value is ignored). The filter only narrows what you could already see — it never
crosses a tenant or a grant boundary.
The response also carries categories and tags: every distinct label on the
agents you could list, whatever the filter and the page — the choices a filter
menu offers. A private agent you cannot see adds none.
Agents over A2A¶
An agent its organization serves over A2A answers JSON-RPC at
POST /api/v1/a2a/{agent_id}, with the same keys as /run; its agent card is at
GET /api/v1/a2a/{agent_id}/.well-known/agent-card.json and needs none.
PUT /api/v1/agents/{id}/a2a with {"enabled": true} turns it on, for a caller
holding agents:publish on that agent.
curl -X POST "$BASE/api/v1/a2a/$AGENT_ID" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "message/send",
"params": {"message": {"role": "user", "messageId": "m1",
"parts": [{"kind": "text", "text": "Review clause 7"}]}}}'
The answer is a Task whose id is the run's and whose contextId continues the
conversation. tasks/get reads one again. Channels has the
rest.
Remote agents this organization delegates to are registered at
/api/v1/external-agents: GET lists them (agents:view); POST with a
card_url, an optional slug and an optional API key secret_id reads the card
and registers it, and POST /{id}/refresh reads it again; DELETE /{id} removes
one (agents:edit for all three).
The ML services¶
Four of the platform's services answer on their own, with no conversation and no
agent behind them: document analysis, OCR, speech to text and personal data
detection. They are gated on ml:invoke rather than agents:run, and
The ML services is their reference.
curl -X POST "$BASE/api/v1/ml/privacy/pii" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "write to ada@example.com"}'
Worked examples¶
Each one runs with an organization key in $AGENTICOS_KEY and the API's origin
in $BASE. Create the key under Settings → API with the permissions the
example names; the console shows the key once.
Put a document into a knowledge base and search it — a key with
collections:view and collections:edit (the Knowledge ingest preset).
Ingestion runs in the background, so a search right after the upload may not find
the document yet; GET /api/v1/kb/$KB_ID/documents shows its status, and
GET /api/v1/kb/$KB_ID/ingestion answers complete once none is still processing.
# Find the knowledge base, upload a file into it, and search it.
curl "$BASE/api/v1/kb" -H "Authorization: Bearer $AGENTICOS_KEY"
curl -X POST "$BASE/api/v1/kb/$KB_ID/documents" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-F "file=@policy.pdf"
curl -X POST "$BASE/api/v1/rag/search" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d "{\"collection_name\": \"$COLLECTION_NAME\", \"query\": \"refund window\"}"
Run an agent and read what it cost — agents:run and runs:view. The run
read carries its status, its tokens and its cost.
RUN_ID=$(curl -s -X POST "$BASE/api/v1/agents/$AGENT_ID/run" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "Summarise this week'"'"'s tickets"}' | jq -r .run_id)
curl "$BASE/api/v1/runs/$RUN_ID" -H "Authorization: Bearer $AGENTICOS_KEY"
Invite a member — members:manage. The invitation is emailed; the response
carries its token once, for an inviter whose mail may not arrive.
curl -X POST "$BASE/api/v1/orgs/$ORG_ID/invitations" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "new.hire@example.com", "role": "member"}'
Retry what was rate limited, and nothing else. A 429 carries
Retry-After; every other refusal is final for that request, and a 401 means
the key is gone — revoked, expired, or its issuer removed — so retrying it only
spends the limit.
import time
import httpx
def call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Response:
for _ in range(5):
response = client.request(method, path, **kwargs)
if response.status_code != 429:
response.raise_for_status()
return response
time.sleep(int(response.headers.get("Retry-After", "60")))
response.raise_for_status()
return response
client = httpx.Client(
base_url="https://agenticos.example.com/api/v1",
headers={"Authorization": f"Bearer {KEY}"},
)
print(call(client, "GET", "/me/permissions").json())
Streaming¶
Two WebSocket endpoints, for two audiences.
/api/v1/ws/agent— the authenticated one the console uses. A frame carryingagent_idruns that published agent; a frame without one gets the general assistant. A frame'sconversation_idcontinues that conversation;nullstarts a new one, and leaving the key out continues the one this socket last wrote to. Authenticate with the subprotocolaccess_token.<token>, where the token is a session JWT. An organization API key is refused here: a turn on this socket is a person at the keyboard, with their personal connections. Integrations run agents withPOST /api/v1/agents/{id}/run./api/v1/embed/{public_key}/ws— the public one behind an embed, for a visitor who has no account.
Both stream tokens as they arrive (an agent with an output guardrail streams a step at a time, see Guardrails) and both produce an ordinary run, with the same books as everything else.
A third, /api/v1/ws/events, only listens. It is how an open console
keeps up with changes made elsewhere:
authenticated the same way, with the organization in ?organization_id=, it
sends one JSON frame per successful public write in that organization —
resource, id, action (created, updated or deleted), surface
(console, api_key, mcp or assistant) and who made it — and only for rows
the caller may read.
Voice calls¶
A voice call has a socket of its own, because a call is not a turn
loop: /api/v1/ws/voice for a member, and
/api/v1/embed/{public_key}/voice/ws for a visitor on a widget or a hosted
page (admission). Both speak the same frames,
defined in backend/app/schemas/voice.py.
The console socket authenticates as /ws/agent does: the token as a
Sec-WebSocket-Protocol entry access_token.<JWT> (or the same-origin cookie),
never in the query string, and the organization as ?organization_id=. It
closes with 4001 without a valid session and 4003 when the caller is not a
member or authenticated with an organization API key: a call is a person at the
microphone, as a chat turn is a person at the keyboard.
The embed socket closes with 4029 past a rate limit and 4003 for any
refusal about the embed. Every heartbeat re-checks the session, so a call does
not outlive a sign-out.
The client sends JSON frames, and over the relay binary audio too:
| Frame | Fields | When |
|---|---|---|
start |
agent_id, conversation_id, sdp_offer (WebRTC), resume_of and resume_token (a reconnect), context (an embed's variables) |
First, within 10 seconds |
approval |
id, decision (approve or deny) |
Deciding an approval the call is waiting on |
mute |
muted |
The relay: the microphone muted |
played |
bytes |
The relay: how much of the agent's audio has been played |
end |
- | Hanging up |
| binary | 16-bit PCM at the model's input rate | The relay: the caller's audio |
The server sends:
| Frame | Fields | Means |
|---|---|---|
answer |
sdp |
WebRTC: the provider's answer for the browser's peer connection |
ready |
call_id, run_id, conversation_id, transport, input_sample_rate, output_sample_rate, cap_seconds, resume_token |
The call is set up. resume_token is sent only here, to this caller; a reconnect sends it back |
state |
state: connecting, listening, user_speaking, thinking, agent_speaking, interrupted, tool_running, awaiting_approval |
Who is talking, or what the agent is doing |
caption |
role, turn_id, part, and one of delta (append), text (replace with the screened text so far) or final |
A live caption |
turn_saved |
message |
A finished turn, as the message it was saved as; it replaces the captions carrying its turn_id |
tool |
name, status (running or done) |
A tool the agent is running |
approval_required |
id, tool, args, said, timeout_seconds |
An approval for the person on the call: the exact arguments, and what they were heard to say |
approval_unavailable |
tool, args |
A tool call this caller cannot approve by voice |
approval_settled |
id, outcome (approved, denied or expired) |
How an approval ended |
notice |
kind, message (optional) |
Something to say in words: a guardrail hit, a limit being reached |
limit |
seconds_left |
The call's time is nearly up |
clear_playback |
- | The relay: drop the agent's queued audio |
ended |
reason, message |
The call is over |
| binary | 16-bit PCM at the model's output rate | The relay: the agent's audio |
A visitor on a widget or a hosted page is told less. ready leaves out
run_id; its cap_seconds is the call's limit - the agent's longest call, or
what is left of a call being resumed. The tool frames follow the
embed's show_tool_steps and show_tool_results switches, as a typed reply's
do. Without tool steps no tool frame is sent and tool_running is sent as
thinking. Without tool results, approval_unavailable is sent with empty
args. A visitor's turn_saved carries only the message's id, role,
content, interrupted and turn_id, whatever the switches: never its run,
agent or version ids, its cost or its tool calls.
A reconnect proves it holds the call. A start with resume_of must also
carry the resume_token from that connection's ready. Without it, or with a
wrong one, the reconnect is refused as resume_refused and the call it names
is not touched. The server keeps only a hash of the token. A call already made
of 20 connections is not resumed again: its next reconnect is refused as
resume_refused too.
A socket that sends no start within 10 seconds gets ended with no_start.
A call that will not start answers with ended and a stable reason:
no_agent, forbidden, not_configured, bad_offer, data_channel,
resume_refused, call_limit, not_priced, budget, busy or line_busy,
with a message safe to show. busy is said only after a call the same person
hung up a moment ago has had up to four seconds to finish ending. A start that
fails for any other reason answers ended with error.
A visitor refused as not_configured, not_priced or budget, or a visitor's
call that ends as budget, gets unavailable instead,
with the message "Calls are not available right now.": the code and the full
message are the agent's operator's to know. A call into an archived
conversation is refused as forbidden, as a typed message into one is refused.
A call that ran ends with user, time_limit, budget, disconnected,
revoked, guardrail, shutdown or error; time_limit when the agent's
longest call was reached. A visitor's call ends as revoked when its embed is
paused, has its calls
switched off or is pointed at another agent. No frame ever carries a provider
credential.
Five HTTP routes go with it:
| Route | Gate | Answers |
|---|---|---|
GET /api/v1/agents/{agent_id}/voice |
agents:run on that agent, a grant included; 404 otherwise |
Whether a call would start, why not (no_voice, not_offered or no_key), the transport and the longest call |
POST /api/v1/agents/{agent_id}/voice/preview |
agents:edit on that agent, a grant included; 404 otherwise |
A draft Voice section's voice saying one fixed sentence, as audio/wav. Cached per voice and language; RATE_LIMIT_VOICE_PREVIEW_PER_MINUTE per person |
GET /api/v1/providers/realtime-models |
agents:view |
The realtime catalog the Voice section picks from |
GET /api/v1/voice/usage?start=…&end=… |
runs:view |
Call seconds and calls for calls started at or after start and before end, by agent (a deleted agent's calls as one row with no id or name). An end not after start is refused with 422 |
GET /api/v1/runs?modality=voice |
runs:view |
Calls only (text for typed runs only); a call that reconnected is one row carrying voice_chain, every connection summed |
An approval that belongs to a live call is decided in the call:
POST /api/v1/approvals/{id} refuses it with 400.
Errors¶
One envelope, everywhere:
{
"error": {
"code": "NOT_FOUND",
"message": "Agent not found",
"details": { "agent_id": "..." }
}
}
details carries values rather than rows, so it names the field that explains a
refusal and never a database record. When a refusal is about something the
caller submitted, details.fields is a list of {field, message} — which is
what lets a form mark the input instead of showing a sentence somebody has to
re-scan the page for.
A 401 carries WWW-Authenticate: Bearer. A cross-tenant read answers 404,
not 403, for the reason above.
A run that started and then failed names itself. POST /agents/{id}/run
answers 502 with MODEL_CALL_FAILED when the model provider refused or failed
the request - every model of a fallback chain included - and 500 with
RUN_FAILED for any other failure once the run had begun. Both carry
details.run_id and details.status, and details.conversation_id when the run
wrote to one, so a retry continues that thread instead of opening another; and message says why
in the platform's words rather than in the provider's raw response. The run is
recorded as failed with what it spent, so GET /api/v1/runs/{run_id} reads it;
sending the request again starts a new run.
Retries, idempotency and revisions¶
v1 has no revision preconditions: no ETag, no If-Match. A write is applied
when it arrives, and the last one wins. Idempotency-Key is honoured on one
route, the asynchronous run.
GET,PUTandDELETEare safe to repeat. Reading twice reads twice; deleting what is already gone answers404, which a retry can treat as done.PATCHis safe to repeat with the same body, and only then: two clients patching one field overwrite each other rather than conflicting.POSTis not. A repeatedPOST /agents/{id}/runstarts a second run and is billed as one; a repeated create makes a second row. After a timeout or a dropped connection, look before you retry:GET /api/v1/runs?agent_id=…&surface=apilists the runs newest first, and a list read finds a row the first attempt did create.- An asynchronous run is, with an
Idempotency-Key. SendPrefer: respond-asyncand a key of your choosing, at most 255 characters, and a retry with the same key and the same body answers the same run request, in whatever state it is in. The same key with a different body is a409, never a second run. Keys are per organization. A synchronous call carrying the header is refused with a400rather than letting a caller believe a retry is safe. 429is the one refusal to retry, after theRetry-Afterit carries. Every other4xxis final for that request.
Conventions¶
| Prefix | /api/v1 |
| Create | POST, 201 |
| Partial update | PATCH |
| Delete | DELETE, 204, no body |
| Pagination | skip (≥ 0) and limit (1–100) query parameters; list responses carry items and total |
| Paths | kebab-case |
Stability, honestly¶
The public API has a written compatibility promise; the rest of /api/v1 does
not. The routes an API key may call are listed in their own OpenAPI document at
/api/v1/public/openapi.json, served in every environment. Within v1 a change
to one of them is additive — a new route, a new optional field, a new response
field or enum value — and a client must ignore response fields it does not know.
Removing or renaming something there happens only after it has been marked
deprecated in that document for at least 90 days and listed in the
release notes; a change that cannot be made that way goes into
/api/v2, beside v1.
The console reads the same document as a page: Settings → API → API reference lists every endpoint with its parameters, body, responses and a ready curl call.
The first rename under that rule is apps (#2071): what were artifacts are served at
/api/v1/apps and /api/v1/public/apps, and the old /api/v1/artifacts and
/api/v1/public/artifacts paths answer the same way, marked deprecated, until
the 90 days are up. Permission names (artifacts:view, artifacts:edit), the tool
names and the addresses a published page is served from keep their names.
The console's own routes carry no such promise and change with the console; a key cannot call them. There is no client library yet.
What the public API does not do yet, so a client is not built on it:
- Virtual Tables are not on it; reading and writing a table with a key is #1795.
- No streaming for a key. The chat socket takes a person's session only.
- No idempotency keys and no revisions, as above.
The agent spec carries its own promise: it is versioned and only moves forward.
Recap¶
/docson the deployment is the generated reference; it is off in production by design.- Three ways in: an organization API key, a JWT, or the console's cookie.
- A key carries its issuer's authority narrowed to its permissions and to the issuer's current role, is shown once, and works on the public API only.
- A key acts in its own organization; a session reads
X-Organization-Idand falls back to the default, personal or only organization without it. The wrong one looks like a missing resource. - Running an agent over HTTP is the same runner — budget, approval and audit all apply.
- A script gets its own key without a browser — sign in, issue, revoke — and
examples/public-api.shruns that round trip. - Only
429is worth retrying; aPOSTis not idempotent, so look before repeating one - except an asynchronous run sent with anIdempotency-Key. Prefer: respond-asyncturns a run into a202and a run request to poll, so a long run does not hold a connection open.- The public API is in
/api/v1/public/openapi.jsonand changes only additively within v1, with 90 days' deprecation; there is no SDK yet.