HTTP API reference
Every invocation endpoint — flow invoke, SSE streaming, single-node calls, pre-warm — plus the Client Builder management API, Instance creation, adapter-mapping preview, with authentication, request and response fields, rate limits and daily quotas, and the structured error envelope.
View as MarkdownThis page is the reference for Axiom's invocation HTTP surface: invoking a
flow (POST /v1/flows/invoke), streaming a pipeline-mode flow
(POST /v1/flows/invoke/stream), invoking a single node, pre-warming a
flow (POST /v1/flows/warm), the error envelope, and rate limits — plus
the Client Builder management API that
axiom client drives. For a guided walkthrough, start with
Invoke a flow via API. For the exact
input and output schema of your flow or package, use the
live OpenAPI specs — they are generated from the
compiled artifact and are always current.
Base URL and authentication
All invocation endpoints are served by the Axiom gateway at
https://api.axiomide.com under the /invocations path prefix. For example:
POST https://api.axiomide.com/invocations/v1/flows/invokeEvery request must carry an API key as a bearer token:
Authorization: Bearer <your 64-character API key>API keys are 64-character hex strings created under Console → API Keys
in the app, or minted automatically by axiom login (named cli). The raw
key is shown exactly once at creation; the platform stores only its SHA-256
hash. See Create and manage API keys.
The key resolves to your tenant. Every execution it starts is scoped to that tenant — the platform enforces isolation, so a key can never invoke another tenant's flows or read another tenant's data (Sandboxing and tenancy).
A missing, invalid, or revoked key gets HTTP 401 with the body:
{"error": "unauthorized"}Revocation takes effect on the key's very next use — there is no key cache.
App keys (invoke-scoped, safe to ship in a browser bundle)
Besides full-access keys, a tenant can mint app keys: public, invoke-scoped credentials for browser apps built on Axiom flows. An app key is the Supabase-anon-key model — long-lived, embedded in your frontend's JS bundle, and extractable by design. It is safe to publish because of what it CANNOT do, not because it is hidden:
- it can only invoke the flows (graph IDs) it was minted for — plus follow the runs it starts (per-execution reads, the run-progress stream, HITL resume) and pre-warm those same flows;
- every management surface — key listing/minting, secrets, packages, graphs, the editor APIs — answers 403, reads included;
- per-key rate caps (default 5 requests/second, burst 10) apply on top of the tenant limits, in every environment;
- anything user-specific must be authorized inside the flow itself (for example jwt-tools verification against your app's own users table). The platform cannot know your app's users; your flows do.
An app key is axapp_ + 64 hex characters, presented exactly like any other
key:
Authorization: Bearer axapp_3f9c…Mint one with the CLI (a full-access login session is required; an app key can never mint keys):
axiom app-key create --name my-app \
--graph <graph-id> \
--origin https://myapp.example.com \
--rps 5 --burst 10or via POST /api/v1/api-keys with {"name": …, "scopes": ["invoke"], "graph_ids": […], "allowed_origins": […], "rate_rps": …, "rate_burst": …}.
The response includes the raw key once, like every key mint.
Origin allowlist and CORS. The gateway answers cross-origin preflights
for /invocations/* from the union of all registered app-key origins, and
echoes Access-Control-Allow-Origin on actual requests only for origins in
the presenting key's own allowlist. A browser request from an origin outside
the key's allowlist is refused (403 origin not in app key allowlist). Be
clear about what this is: the origin allowlist is anti-abuse, not a
security boundary — non-browser clients don't send an Origin header and
CORS does not bind them. The security boundary is the graph restriction, the
rate caps, and your in-flow authorization.
Re-pointing a key at a rebuilt flow, or a new origin. Every compile mints a
new graph id, and apps move between origins — so an app key's restrictions
are editable in place. You never re-mint, and never change the axapp_ value
your app already ships:
axiom app-key update <key-id> --graph <new-graph-id>
axiom app-key update <key-id> --origin https://myapp.example.com
axiom app-key update <key-id> --clear-origins # remove the origin restrictionor PATCH /api/v1/api-keys/{id} with {"graph_ids": […]} and/or
{"allowed_origins": […]}. Each list you send is a full replacement; a
field you omit is left untouched; "allowed_origins": [] clears the origin
restriction ("graph_ids": [] is a 400 — an app key always has at least one
invocable flow). graph_ids and allowed_origins are the only patchable
fields: scopes, rate caps and expiry are fixed at mint, and any other field
in the body is a 400 rather than a silently ignored write. Patching another
tenant's key, a revoked key, or a full-access PAT is a 404.
Patching origins is safe precisely because the origin allowlist grants nothing (see above) — it is anti-abuse CORS only. The fields that do grant stay immutable. A patched origin is enforced on the very next authenticated request; cross-origin preflights consult a short-lived cached union of all registered origins, which revalidates on a miss, so a newly patched origin becomes preflightable immediately as well.
GET /api/v1/api-keys (axiom app-key ls) returns each key's pinned
graph_ids and allowed_origins, so a client can tell what is currently
restricted before patching.
Invoking a graph outside the key's set fails with:
{"error": "app_key_graph_denied", "message": "app key is not authorized for graph <id>"}Rate limits
Two per-tenant token buckets apply to authenticated requests:
- All routes: 100 requests/second, burst 200.
- Invocation routes (everything under
/invocations/): an additional 5 requests/second, burst 10 — a beta guardrail.
The invocation burst limit is enforced at ingress admission as well as at
the gateway, so it applies even on the direct-to-ingress routing the axiom
CLI defaults to in development (which bypasses the gateway entirely).
Exceeding either returns HTTP 429 with a Retry-After: 1 header and the
body {"error":"rate limit exceeded"}.
App keys additionally carry per-key caps (their own token bucket, keyed on the key, default 5 r/s burst 10 — chosen at mint). These apply in every environment, independently of the tenant guardrails, and produce the same 429 response.
Daily quotas
On top of the per-second rate limits, each tenant has two daily budgets. Both reset at midnight UTC. The beta defaults are:
- 2,000 invocations per day. An invocation is one authenticated request that triggers compute: a flow invoke (plain or streaming), a single-node call, a paused-flow resume (including resume webhooks), or a debug-session fork.
- 500 executions per day. An execution is one flow run. Most invocations start exactly one, but executions a flow spawns internally — child executions from parallel branches, runtime graph mutations, debug forks — each draw from the same budget. A flow that spawns 10 parallel branches costs 10 executions.
A request that would exceed either budget is rejected before any compute
runs, with HTTP 429, a Retry-After header, and a structured body whose
retry_after_seconds counts down to the next UTC midnight:
{
"error": "quota_exceeded",
"message": "daily invocations quota exceeded (cap 2000)",
"retry_after_seconds": 74380
}If a running flow exhausts the execution budget mid-run (for example when
spawning a large number of parallel branches), the flow fails with the same
quota exceeded message as its error.
The defaults are per-tenant and the operator can raise or lower them for your tenant — if you hit them with a legitimate workload, get in touch. Two related responses you may also see on invocation routes:
| Status | Body error | Meaning |
|---|---|---|
| 403 | tenant_suspended | Your tenant has been disabled by the operator. No invocations or resumes are accepted. Contact the operator. |
| 503 | quota_unavailable | The quota service could not be reached, so the request was rejected as a safety measure. Transient — retry after retry_after_seconds (5 s). |
Invoke a flow
POST /v1/flows/invoke executes a compiled artifact once and returns the
result inline (with wait) or an execution ID immediately (without).
curl -X POST 'https://api.axiomide.com/invocations/v1/flows/invoke' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD",
"input": { "text": "hello" },
"wait": true
}'Request fields
| Field | Type | Required | Meaning |
|---|---|---|---|
graph_id | string | yes | The compiled artifact to execute. Get it from the Use via API dialog or the flow's live OpenAPI spec; editing a flow produces a new artifact with a new ID. |
input | object | one of input/payload | The entry node's input message as a JSON object. The platform converts it to the typed message, and decodes the result back to JSON. |
payload | string (base64) | one of input/payload | The entry node's input message as protobuf-encoded bytes, base64-encoded. For callers that already speak protobuf. input takes precedence if both are set. |
wait | boolean | no | true blocks until the execution completes and populates result. Default false: the response returns immediately with just accepted and execution_id. |
timeout_seconds | integer | no | How long a waiting call blocks before giving up (default 30). |
config_id | string | no | Named flow config profile to apply; when omitted the default hierarchy applies (tenant default → flow default). See Flow configs. |
debug_session_id | string | no | Streams per-node debug events for this execution to the named debug session. Can also be sent as the X-Debug-Session-Id header, which takes precedence over the body field. See Debug a flow. |
idempotency_key | string | no | Dedups retries: a call with the same key from the same tenant within 5 minutes of the first returns that first call's execution_id instead of executing the flow again — at most one execution per key, even if the retry lands while the first call is still in flight. Can also be sent as the Idempotency-Key header, which takes precedence over the body field. When omitted, the platform falls back to a fingerprint of graph_id, config_id, and the payload/input, so byte-identical retries still dedup even if you never set this field. |
Response
An accepted invocation returns HTTP 202:
{
"accepted": true,
"execution_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"result": {
"success": true,
"output": { "text": "hello" },
"completed_at": 1765432100000
}
}-
execution_id— a 32-character hex ID assigned when the request is accepted. It is also the execution's trace ID, so one ID references the execution everywhere: results, debug events, and traces. -
result— present only withwait: true.result.outputis the terminal node's output message decoded to JSON (when you invoked withinput);result.payloadcarries base64-encoded protobuf bytes instead when you invoked withpayloador when JSON decoding was not possible. -
result.error_message— present withwait: truewhen the execution was accepted and delivered but ran to a failure (result.successisfalse), for example because the input did not match the flow's start-node schema. The same diagnostic is also copied to the top-levelerror_messageso a caller can read it without descending intoresult. The response is still HTTP 202 (the request was accepted); the failure is informational, not a transport error. -
Without
wait, the body is just{"accepted": true, "execution_id": "..."}and the flow runs asynchronously. -
execution_status— populated only on thewait: trueaccepted-with-no-result path, naming which non-terminal outcome you got:PAUSED(a human-in-the-loop step is waiting) versusRUNNING(your wait simply elapsed first). Empty means the status could not be determined — treat that as still running. -
pause_id— accompaniesexecution_status: "PAUSED": the waiting pause, ready to pass straight to the resume endpoint. For pauses discovered later (or after a reconnect), list them withGET /v1/executions/{id}/pauses. -
mocked_nodes— present only when the invoked artifact was compiled with authoring mocks. It lists the node ids answered from fixtures instead of being executed:{ "accepted": true, "execution_id": "4bf92f3577b34da6a3ce929d0e0e4736", "mocked_nodes": ["classify", "notify"], "result": { "success": true, "output": { "channel": "#support" } } }The field is returned on every unary invoke of a mocked artifact — with and without
wait, and on a failed run. A response carrying it is never evidence that the flow works against the real services: treat it as a wiring check only, and re-run against an artifact compiled withaxiom flow compile --no-mocksbefore trusting the result. Its absence means every node ran for real. Mocked artifacts cannot be published, so a marketplace flow never returns this field — see Mock nodes while authoring a flow.
Failures return a non-202 status with an error body — see Error envelope.
Stream a flow over SSE
POST /v1/flows/invoke/stream invokes a flow in pipeline mode and streams
result frames back as Server-Sent Events. Use it for flows that emit a
sequence of output frames rather than a single result
(Execution model).
curl -N -X POST 'https://api.axiomide.com/invocations/v1/flows/invoke/stream' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD",
"input": { "text": "hello" }
}'The request body accepts graph_id, input (or payload),
timeout_seconds, config_id, and debug_session_id exactly as in
Invoke a flow. wait does not apply — frames are always
delivered as they are produced.
The response is Content-Type: text/event-stream. Each event is one
data: line of JSON:
data: {"execution_id":"4bf92f3577b34da6a3ce929d0e0e4736","frame_index":0,"payload":{"text":"chunk 1"},"is_final":false}
data: {"execution_id":"4bf92f3577b34da6a3ce929d0e0e4736","frame_index":1,"payload":{"text":"chunk 2"},"is_final":true,"success":true}Frame fields:
| Field | Meaning |
|---|---|
execution_id | Same ID as the unary endpoint — also the trace ID. |
frame_index | Position of this frame in the stream, starting at 0. |
payload | The terminal node's output message for this frame, decoded to JSON. Omitted when the frame carries no decodable payload. |
is_final | true on the last frame; the stream ends after it. |
success | true on the final frame of a successful execution. On failure the field is omitted entirely (it is never serialized as false) — treat a final frame without success: true as failed and read error. |
error | Error message when a frame reports failure. |
The stream times out after 30 seconds by default; set timeout_seconds to
extend it. A timeout ends the stream with a final frame whose error is
"timeout waiting for pipeline result".
Invoke a single node
A published node can be called directly, without composing a flow. This is the endpoint that Client Builder SDKs use — one method per node.
curl -X POST 'https://api.axiomide.com/invocations/v1/nodes/axiom-official/pdf-chunker/1.0.0/Chunk' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "url": "https://example.com/doc.pdf" }'- Path forms:
POST /v1/nodes/{owner}/{package}/{version}/{node}(name-based, as in the example) orPOST /v1/nodes/{node_ulid}(the node's 26-character registry ID). - Request body: the node's input message as a plain JSON object — no envelope.
- Response: HTTP 200 with the node's output message as JSON. A node
that returns an error gets HTTP 422 with
{"error_message": "..."}; an unknown or undeployed node gets HTTP 404. A payload that doesn't match the node's current input schema is rejected with HTTP 400: an unknown or renamed field name (rejected by JSON decoding), or an enum field set to an ordinal that names no declared value, yields{"execution_id": "...", "error_message": "invalid value for <Message>: field \"<name>\" carries enum ordinal <n>, which names no declared value ..."}. Re-creating a payload recorded against an older schema version is the usual cause — re-record it against the current schema. - Streaming nodes: a pipeline-mode node (or any request with
Accept: text/event-stream) streams its output frames as Server-Sent Events instead of a single JSON response.
Stream input into a pipeline node
A pipeline node can be fed N input frames over a live connection: one JSON object per LINE, each dispatched as it is read, so the node starts producing output before the input has ended — and the input never has to fit in memory, or end at all.
There are three forms:
| Form | Transport | Durable? | Use it for |
|---|---|---|---|
| Split shape | 3 half-duplex HTTP requests | Yes — replay from offset, reconciled frame count | Bulk or log-shaped ingestion where losing nothing matters |
| Live mode | 1 WebSocket, both directions at once | No — ephemeral, no execution row, no replay | Interactive sessions: token relays, live tails, dialogue |
| Single-call | 1 HTTP request, buffered response | Yes | Small streams where incremental output is not needed |
Use the split shape unless you specifically want an interactive session, in which case use live mode and accept that nothing is written down.
The split shape (three half-duplex requests)
Upload and output travel on separate requests, so neither direction is ever waiting on the other:
| Leg | Request | Returns |
|---|---|---|
| 1. Create | POST /v1/nodes/streams with {"node_ref": "..."} | 201 {"stream_id", "input_url", "output_url", "expires_in"} |
| 2. Output | GET /v1/nodes/streams/{id}/output | Server-Sent Events, one frame per event |
| 3. Input | POST /v1/nodes/streams/{id}/input with Content-Type: application/x-ndjson | {"stream_id", "execution_id", "input_frames"} after the upload completes |
node_ref is either {owner}/{package}/{version}/{node} or a node ULID.
# 1. create the stream
STREAM=$(curl -sS -X POST \
'https://api.axiomide.com/invocations/v1/nodes/streams' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"node_ref":"me/tools/1.0.0/StreamRecords"}' | jq -r .stream_id)
# 2. attach the output channel FIRST, in the background, so frames print live
curl -N -sS "https://api.axiomide.com/invocations/v1/nodes/streams/$STREAM/output" \
-H "Authorization: Bearer $AXIOM_API_KEY" &
# 3. upload frames as they are produced
# NOTE -T - (not --data-binary @-): --data-binary reads ALL of stdin into
# memory before sending, so it cannot stream — it silently batches.
tail -f events.ndjson | curl -sS -X POST -T - \
"https://api.axiomide.com/invocations/v1/nodes/streams/$STREAM/input" \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/x-ndjson'The CLI equivalent is
axiom invoke --input-stream, which drives all
three legs for you.
- Attach whenever you like. The output channel replays from the beginning, so a reader that attaches after the upload started — or reconnects after a drop — still receives every frame from the first. Attaching before you upload simply means the first frame prints the moment it exists.
- Reconcile. Compare
input_framesin the upload's response against the number of frames you sent. They must be equal. The CLI makes this comparison on every run and fails loudly on a mismatch; if you drive the API yourself, make it too. - One upload per stream. A stream id is single-use: a second upload is
refused with
409, so two invokes can never interleave into one reader's view. Retrying means creating a new stream — one cheap round trip. - Ids are platform-generated and tenant-scoped. You cannot choose a stream
id, and an id belonging to another tenant is indistinguishable from one that
does not exist (
404). - Streams expire after 10 minutes, which bounds how late a reader may attach or reconnect.
Live mode (one WebSocket, full duplex)
GET /v1/nodes/{ref}/live
Upgrade: websocket
Connection: Upgrade
Authorization: Bearer <api-key>Frames travel up and down the same connection at the same time. {ref} is
either {owner}/{package}/{version}/{node} or a node ULID — the same references
the invoke route accepts, with /live appended.
- One WebSocket TEXT message == one node frame. Inbound messages are NDJSON objects, exactly as the split shape's upload lines. Outbound messages are the same frame JSON the SSE forms emit, so a consumer decodes one struct regardless of transport.
- Terminate by closing the socket, not by sending a final payload frame.
- The first message the server sends is
SESSION_READY, carryingexecution_id, the limits, and the ephemerality contract.
# The CLI drives this for you:
axiom invoke me/tools/Chat --live -:::danger[Live mode is EPHEMERAL — nothing is written down] A live session is deliberately fire-and-forget:
- No durable execution row.
GET /v1/executions/{id}will never find it. Do not poll the timeline for a live session'sexecution_id; it is a trace identifier, not a record. - Pinned to one ingress pod. If that pod restarts, the session dies.
- No replay and no redelivery. Recovery means reconnecting and re-sending.
This is the trade that buys full duplex. If you need a durable record of what was ingested, use the split shape — it exists for exactly that, and its output channel replays from offset precisely because it is the durable path. :::
A slow consumer loses its session. The platform holds a small bounded buffer
of output per session. If your client stops draining it, the session is
closed with WebSocket code 1013 (Try Again Later) and a reason naming the
cause — it is not buffered indefinitely and it does not block the node. That is
the honest outcome for a fire-and-forget path: a client that cannot keep up gets
told so, rather than silently costing the platform memory. Read your socket
promptly, or use the split shape, whose Redis-backed channel lets you attach and
drain at your own pace.
Three bounds apply to every session, and none is redundant:
| Bound | Value | What it protects |
|---|---|---|
| Per input frame | 16 MiB | One oversized message |
| Per session OUTPUT | 64 MiB | A runaway node on one socket |
| Wall clock | 5 minutes | The platform's node ceiling |
A session also holds one of your tenant's inflight execution slots for its whole lifetime — it is a running node, not a subscription.
Auth: the handshake GET carries Authorization: Bearer <key> like any
other request, and the token must have write scope — a live invoke runs the
node. (A WebSocket handshake is a GET, so this is enforced explicitly rather than
inferred from the method.) Every rejection — scope, unknown node, generic node,
unary node — is decided before the upgrade and arrives as an ordinary HTTP
status you can read, not as a socket close.
The single-call form (buffered)
Posting NDJSON straight to POST /v1/nodes/{ref} with
Content-Type: application/x-ndjson still works, and the input side is still
genuinely streamed — frames are dispatched to the node as they are read.
:::warning[The response is buffered, so output is not incremental] On the single-call form the platform holds every output frame until your upload has completed, and only then writes the SSE response.
This is deliberate. NDJSON up and Server-Sent Events down on ONE request is
full-duplex HTTP, and an HTTP client — including the reverse proxy in front of
this API — stops writing a request body once the response arrives. A response
that begins early therefore truncates the rest of your upload. (Measured before
this was fixed: a 7-frame stream paced 3s apart delivered only 4 frames, and the
run still reported success: true.) Buying correctness here costs
incrementality.
If you want output as it happens — a token relay, a live tail, an interactive session — use the split shape above. :::
Rules that apply to all three forms
- Pipeline nodes only. A unary node consumes exactly one input, so frames after the first would be accepted by the transport and silently discarded. Streaming into one is rejected with HTTP 400 rather than half-working.
- Generic nodes are refused. A Generic node is a type template, not a runnable endpoint; invoke an Instance that binds it instead.
- Output is Server-Sent Events on the two HTTP forms and WebSocket TEXT
messages in live mode — the same frame JSON either way. The terminal frame carries
input_frames, the number of inbound frames the platform accepted — compare it against the number you sent. - Limits: 16 MiB per frame (the unary 16 MiB body cap becomes a PER-FRAME cap), 64 MiB and 100 000 frames per stream, and 5 minutes of wall clock — the platform's node ceiling. Blank lines are skipped.
- Rejected frames: a frame that fails validation (malformed JSON, a field
the node's schema doesn't declare, an enum ordinal that names no declared
value, or an over-size frame) is rejected AT that frame. If it is the first
frame the response is a normal HTTP error; otherwise the stream ends with an
SSE
errorevent naming the frame index, e.g.{"is_final": true, "error": "frame 7: invalid JSON for Record: ..."}. Output already emitted for earlier frames stands. - Durability: a streamed direct invoke is synchronous and is not redelivered. On the two HTTP forms its execution record stores the first frame plus the frame count, not the concatenation. Live mode writes no execution record at all (see the warning above). If the connection drops, re-stream.
- Incremental consumption is per-language. Every language receives every frame; what differs is when. Go, Python and TypeScript nodes see each frame as it arrives. Rust buffers the whole stream before the handler runs. Java and C# begin only after the input ends. If your node must react to frame k before frame k+1 is sent, write it in Go, Python or TypeScript.
A binary-protobuf variant exists at POST /v1/nodes/invoke with the JSON
body {"node_id": "...", "payload": "<base64 protobuf bytes>"}, returning
{"success": ..., "payload": ..., "error_message": ...} — for callers that
serialize the messages themselves. The payload is validated against the
node's current input schema before it is forwarded: bytes whose wire shape
doesn't match (an undeclared field number, or a wire type that conflicts
with the declared field kind) are rejected with HTTP 400 and
{"success": false, "error_message": "payload does not match the node's current input schema: ..."},
as is any payload whose node schema can't be resolved.
Pause and resume a human-in-the-loop step
POST /v1/flows/{execution_id}/resume delivers a person's decision to an
execution parked at a human-in-the-loop pause, and the execution continues
from that node. Authoring the pause itself is covered in
Pause a flow for a human decision.
curl -sS -X POST \
"https://api.axiomide.com/invocations/v1/flows/$EXECUTION_ID/resume" \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"pause_id": "01KZ5G...", "value": {"approved": true}, "resumed_by": "alice@example.com"}'Request fields
| Field | Type | Required | Meaning |
|---|---|---|---|
pause_id | string | yes | Which pause to resume. An execution may hold several sequential pauses. |
value | JSON | no | The decision. Becomes the paused node's input payload. |
resumed_by | string | no | Recorded on the pause for audit. |
value accepts two encodings, both resolving to the paused node's input
message:
- a JSON object whose fields are the fields of that message — the form to write by hand;
- a JSON string holding standard base64 of the already-serialized message — the form the console submits.
An omitted or null value resumes the node on an all-default input. A
value that is neither encoding, or that does not decode into the node's
input message, ends the execution as Failed with a message naming the
resume value; it is never silently replaced by the node's pre-pause input.
A value over 16 MiB is rejected up front with INVALID_RESUME_VALUE.
Response
{
"pause_id": "01KZ5G...",
"status": "RESUMED",
"resumed_at": "2026-08-03T12:00:00Z",
"resume_value": {"approved": true},
"already_resumed": false
}already_resumed is true when someone else resumed this pause first. The
call still returns HTTP 200 and the losing decision is recorded for audit —
resuming is idempotent, not first-write-wins-silently.
Field-naming asymmetry — the request field is
value, notresume_value. The response (and the pause rows below) echo the decision back asresume_value, which invites copying that name into the request. A request body of{"pause_id": …, "resume_value": …}does not error — the unknown key is ignored and the pause resumes on an all-default input, as ifvaluewere omitted. Writevaluein the request; expectresume_valueeverywhere it is read back.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_ARGUMENT | Missing pause_id, or an unparseable body. |
| 400 | INVALID_RESUME_VALUE | value failed the pause's schema check or exceeded 16 MiB. |
| 400 | PAUSE_EXECUTION_MISMATCH | The pause belongs to a different execution. |
| 404 | PAUSE_NOT_FOUND / EXECUTION_NOT_FOUND / CHECKPOINT_NOT_FOUND | No such pause, execution, or stored checkpoint. |
| 409 | PAUSE_NOT_WAITING | The pause already expired or was cancelled. |
| 409 | EXECUTION_NOT_RESUMABLE | The execution reached a terminal status; its pauses can no longer be resumed. |
| 503 | UNAVAILABLE / SECRETS_UNAVAILABLE | A dependency the resume path needs is down. Retry. |
Get an execution's status
GET /v1/executions/{execution_id} returns the durable record of one
execution — the authoritative answer to "did this run actually finish, and
how". GET /v1/executions lists them (newest first, cursor-paginated, with
status/graph_id/time-range filters).
curl -sS "https://api.axiomide.com/invocations/v1/executions/$EXECUTION_ID" \
-H "Authorization: Bearer $AXIOM_API_KEY"{
"execution_id": "4bf92f35...",
"tenant_id": "01KM...",
"graph_id": "01KZ...",
"graph_artifact_id": "01KZ...",
"status": "COMPLETED",
"latest_checkpoint_id": "01KZ...",
"forked_from_execution_id": null,
"mutation_enabled": false,
"lineage_root_execution_id": null,
"mutation_seq": 0,
"created_at": "2026-08-11T12:00:00Z",
"updated_at": "2026-08-11T12:00:41Z",
"completed_at": "2026-08-11T12:00:41Z"
}statusis one ofQUEUED|RUNNING|COMPLETED|FAILED|CANCELLED|FORKED.completed_atis set only on terminal rows.- This endpoint is how a client learns a long run's true terminal state.
A flow with a slow step (an LLM call, a cold start) can outlive a client's
invoke timeout: the CLI or HTTP caller reports a context deadline while the
durable execution runs on — and completes. A deadline on the read is not a
failure of the run; before treating a long invocation as failed, fetch the
execution (the invoke response and the CLI both print its id) and believe
status, not the timeout. - Node-direct invocations (
axiom invokeon a single node) havenode_id/node_fqnset and nograph_id. Fire-and-forget live invocations (replay: falsestreams) create no execution row at all — there is nothing to poll. mocked_node_count(when non-zero) marks the run as served partly from authoring fixtures — a mocked green is never a live verification.- A missing id — or another tenant's id — is a 404; the row shape is identical
to the entries in
GET /v1/executions.
List an execution's pauses
GET /v1/executions/{execution_id}/pauses returns every human-in-the-loop
pause the execution has held, in creation order — the durable pause state
itself, so a client never has to reconstruct it from debug events.
curl -sS "https://api.axiomide.com/invocations/v1/executions/$EXECUTION_ID/pauses" \
-H "Authorization: Bearer $AXIOM_API_KEY"{
"pauses": [
{
"pause_id": "01KZ5G...",
"execution_id": "4bf92f35...",
"node_name": "01JNODE...",
"status": "RESUMED",
"interrupt_payload": {"question": "Approve the plan?"},
"resume_schema": "ApprovalDecision",
"timeout_policy": "CANCEL",
"checkpoint_id": "01KZ5F...",
"created_at": "2026-08-08T12:00:00Z",
"expires_at": "2026-08-09T12:00:00Z",
"resumed_at": "2026-08-08T12:05:00Z",
"escalated_at": null,
"resumed_by": "alice@example.com",
"resume_value": {"approved": true}
}
]
}statusis one ofWAITING|RESUMED|EXPIRED|CANCELLED. An execution holds at most oneWAITINGpause at a time; sequential pauses are normal for multi-turn flows.- A
WAITINGrow'spause_idis what the resume endpoint takes. resume_valueis stored with secret-shaped content redacted at write time.- An execution with no pauses returns
{"pauses": []}— so does an execution id that does not exist for your tenant.
Follow a run's progress
Every execution publishes a stream of progress events as it runs — node lifecycle, edge evaluations, human-in-the-loop pauses, failures. Two endpoints expose it, and they are designed to be used together:
- Pull (durable history):
GET /v1/executions/{execution_id}/events— cursor-paginated pages of everything the execution has emitted so far. Events are retained for 90 days. - Live (WebSocket):
GET /v1/debug-stream?session_id={execution_id}— a socket that first replays the recent head of the stream, then delivers new events as they happen. A read-scoped key is sufficient: the subscription observes an existing run and can start nothing.
# Pull everything emitted so far (page with ?cursor= from next_cursor):
curl -sS "https://api.axiomide.com/invocations/v1/executions/$EXECUTION_ID/events?decode=true" \
-H "Authorization: Bearer $AXIOM_API_KEY"
# Then follow live (websocat shown; any WS client that can set a header works):
websocat -H "Authorization: Bearer $AXIOM_API_KEY" \
"wss://api.axiomide.com/invocations/v1/debug-stream?session_id=$EXECUTION_ID"Browser clients that cannot set headers pass the token as a WebSocket
subprotocol instead: Sec-WebSocket-Protocol: axiom-token.<token>.
The lossless-follow protocol
The live socket replays from the head of a capped buffer (roughly the most
recent 1000 events), so a subscriber that attaches late to a long run could
miss the earliest events on the socket alone. The durable pull endpoint
holds the full history, and every event carries a unique, time-ordered
event_ulid — the same value on both channels. To follow a run of any
length without gaps or duplicates:
- Page
GET /v1/executions/{id}/eventsuntilnext_cursoris absent, recording each event'sevent_ulid. - Open the WebSocket. After the
SESSION_READYhandshake frame it replays the buffered head, then goes live. - Drop any socket event whose
event_ulidyou already saw in step 1.
Short-lived runs can skip step 1 and simply subscribe — the replay covers
them. Events written before the event_ulid field existed surface it as
empty on the pull API; treat those as seen.
Event schema
Each event is a JSON object. Common fields:
| Field | Meaning |
|---|---|
type | Event type (table below). |
event_ulid | Unique, time-ordered id — the dedupe key across pull + live. |
execution_id / session_id | Which run this event belongs to. |
timestamp_ms | Publish time, epoch milliseconds. |
node_id, node_name, instance_id | The placement the event pertains to, where applicable. |
input_bytes, output_bytes, frame_bytes | Base64 of the raw payload proto. Decode with the node's schema, or query the pull endpoint with ?decode=true to receive decoded input / output JSON. |
Forward compatibility: new event types and new fields appear without
notice — ignore anything you do not recognize. Never treat an unknown
type as an error.
| Type | Emitted |
|---|---|
SESSION_READY | WebSocket handshake only — not a stored event, no event_ulid. |
NODE_STARTED / NODE_COMPLETED / NODE_FAILED | Node lifecycle. NODE_COMPLETED carries output_bytes; mocked: true marks an authoring-fixture response. |
NODE_LOG | A log line from node code (log_message, log_level). |
NODE_PROGRESS | One ax.progress(kind, data) frame emitted by node code mid-execution: progress_kind, progress_json (the author's opaque JSON, ≤64 KiB), progress_seq (monotonic per invocation). Rate-limited (~10/s, burst 50); when frames were dropped, the node's terminal event carries a non-zero progress_dropped — treat the sequence as gappy, never silently complete. |
EDGE_EVALUATED | Per-edge condition/adapter outcome (condition_pass, adapter_applied, error). |
NODE_FRAME / NODE_INPUT_FRAME / NODE_STREAM_COMPLETE | Pipeline-flow frame traffic; NODE_STREAM_COMPLETE replaces NODE_COMPLETED for pipeline nodes and carries total_frames / frames_dropped. |
NODE_RETRY_SCHEDULED / NODE_RETRY_STARTED / NODE_RETRY_EXHAUSTED | Durable retry lifecycle (attempt, max_attempts, next_attempt_at_ms). |
HITL_PAUSED / HITL_RESUMED / HITL_RESUME_FAILED / HITL_TIMEOUT / ESCALATION_TRIGGERED | Human-in-the-loop lifecycle. HITL_PAUSED carries pause_id, interrupt_payload, resume_schema, expires_at_ms — everything needed to render the pause and call the resume endpoint. |
FLOW_COMPLETED / FLOW_FAILED | Terminal. FLOW_FAILED may carry a structured failure_payload. |
GRAPH_MUTATED | A mutation-capable node grew the running graph. |
MEMORY_READ / MEMORY_WRITE | Memory-layer activity. Live socket only — not persisted. |
Pull endpoint parameters
| Param | Meaning |
|---|---|
cursor | Opaque cursor from the previous page's next_cursor. |
limit | Page size, default 100, max 1000. |
node_name | Only events for this node. |
event_type | Repeatable; only these event types. |
decode | true decodes input_bytes/output_bytes to message JSON server-side. |
Pre-warm a flow
POST /v1/flows/warm primes a flow's pods ahead of time — a scale-from-zero
nudge with no business logic run — so a subsequent real
invoke doesn't pay a cold start. Call it, for example,
right before you expect a burst of traffic, or after deploying a flow that
has been idle.
curl -X POST 'https://api.axiomide.com/invocations/v1/flows/warm' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD"}'A successful warm returns HTTP 202:
{"warmed": true, "graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD"}warmed: true means the warm was dispatched, not that pods are already
serving — it's fire-and-forget, the same way a cold start on invoke is not
something you wait on. A flow with nothing deployed (for example, one built
entirely from built-in nodes) is a legitimate no-op: you still get
warmed: true back. Only the node packages your tenant is entitled to use
are warmed; this only matters if a flow's saved graph somehow references a
package outside your tenant's access, in which case that package is
silently skipped rather than warmed.
Request fields
| Field | Type | Required | Meaning |
|---|---|---|---|
graph_id | string | yes for API keys; optional for app keys | The compiled artifact to warm — the same id you'd pass to invoke. Must match [A-Za-z0-9_-]+; anything else is a 400. When an app key omits it, the warm expands server-side to every graph in the key's allowlist (see below). |
Two other body fields, tenant_id and flow_owner, are accepted but
always ignored — see the next section. Don't rely on either to select
which tenant a warm applies to.
Warm by app key (presence beacon)
An app key may call warm with an
empty body ({}): the platform expands the warm to the key's own graph
allowlist, so a generated app never has to carry a graph list of its own. The
expansion covers the whole allowlist — there is no graph-level cap; the
fan-out bound sits on packages, the actual unit of cold start (see the
per-beacon package cap below):
{"warmed": true, "graph_ids": ["01JX…", "01JY…"], "held": ["01JX…", "01JY…"], "truncated": 0, "truncated_packages": 0, "window_seconds": 360}graph_ids (== held) is what was dispatched — under the retention model
below that is always every targeted graph, so a beacon is never partially
admitted. window_seconds is how long this touch is worth: the pod's
retention horizon (see below), which is the number to pace a custom cadence
against.
The deferred, ceiling, and truncated fields are vestigial. The
first two described a per-tenant concurrent-warm ceiling that no longer
exists; truncated counted graphs dropped by a graph-level expansion cap
that was retired on 2026-08-21 (it truncated in allowlist order, and because
auth flows are minted last, an app past the cap never had its signup or
login warmed — the flows a cold visitor touches first). All three are
omitted from the response now and remain documented only because previously
generated app clients read them; all are safe to ignore.
The generated browser SDK's Client sends exactly this beacon automatically
— on load, then every 4 minutes while the tab is visible, stopping when it is
hidden. A failed beat retries sooner, not later: it starts at 30 s and doubles
up to the 4-minute cadence, honouring the response's Retry-After header when
present (the platform CORS-exposes Retry-After on this route specifically so
a cross-origin browser fetch can read it — res.headers.get("Retry-After")
returns null on any header the server doesn't list in
Access-Control-Expose-Headers). Pass warm: false to opt out, or call
client.warm() to send one by hand. Passing an explicit graph_id with an
app key still warms just that one graph (and it must be in the allowlist,
else 403 app_key_graph_denied).
How warmth actually works, and its guardrails. Warming is request-driven
and bounded — user nodes deploy scale-to-zero and stay that way (no keep-warm
floor exists). What keeps a pod alive is Knative's own
scale-to-zero-pod-retention-period, set cluster-wide to 5 minutes: any
data-plane request — a warm ping or an ordinary invoke — resets the pod's idle
clock, and the pod then survives roughly 6 minutes (Knative's 60 s
stable-window plus the 5 min retention). window_seconds reports that horizon.
No presence, no warmth: once the requests stop, the pod scales to zero on its
own, with nothing to expire and nothing to clean up.
One limit applies to the endpoint, answered with a 429 and a Retry-After,
never a silent drop:
| Status | error class | When |
|---|---|---|
| 429 | warm_rate_limited | This app key beaconed faster than its per-key warm cap (default 1 warm / 20 s, burst 3). Honour Retry-After (seconds). |
The warm_ceiling 429 that used to appear here no longer exists. It came
from a per-tenant concurrent-warm ceiling that has been removed: because the
ceiling default (4) was smaller than the then-current graph fan-out cap
(8 — itself since retired, see the package cap below), any
app with more than 4 flows in its allowlist — which is most real
app-builder apps — could never be warmed at all. A warm is no longer refused
for holding reasons, because the platform holds nothing.
Per-beacon package cap. The package — one service per package — is the
unit of cold start, so this is the platform's one fan-out bound on warming.
An app's flows mostly share packages (a typical generated app's whole flow
set sits on four to six), but packages-per-graph isn't bounded by anything a
client controls — any public marketplace package is warmable by any tenant,
and a handful of graphs can reference hundreds of distinct packages. So the
platform caps the deduped union of package ids across a beacon's graphs
at 48 (server-tunable) before warming them: truncated_packages in the
response body reports how many were dropped (0 when nothing was), and
truncation is deterministic (first-referenced packages win). Nothing here
changes which graphs are warmed (graph_ids is unaffected); it only bounds
how many distinct packages one beacon can force the registry to authorize
and warm.
Authentication and ownership
Pre-warm authenticates exactly like every other invocation route: your
bearer API key resolves to your tenant (see
Base URL and authentication). That tenant —
never anything in the request body — is what the platform checks the
graph_id against: a graph_id your tenant does not own returns HTTP 403,
the same ownership check invoke performs. An unknown graph_id returns 404.
Either way, no warm is dispatched — a warm request can never be used to
spin up capacity for another tenant's flow.
The tenant_id and flow_owner body fields exist in the request schema
only so a forged value there has nowhere to go — the platform never reads
them. Setting either has no effect; your key's tenant is always the one
enforced.
Rate limits and quotas
Pre-warm shares the same per-tenant budgets as invoke — it does not have a separate limit:
- The rate limits above (100 req/s overall, 5 req/s on invocation routes) apply identically.
- Each warm debits one unit of the daily invocation quota (the same 2,000/day budget a flow invoke, single-node call, or resume consumes — see Daily quotas). It does not touch the separate executions budget, since warming runs no flow.
A tenant that is suspended, or has exhausted its invocation quota, gets the
same 403 tenant_suspended / 429 quota_exceeded responses invoke returns
— see Error envelope.
Errors specific to pre-warm
| Status | error class | When |
|---|---|---|
| 400 | invalid_request | graph_id is missing or doesn't match [A-Za-z0-9_-]+. |
| 403 | forbidden | The authenticated tenant does not own graph_id. |
| 404 | not_found | graph_id doesn't resolve to any flow. |
| 503 | warm_unavailable | The warm dispatcher isn't configured on this deployment; retry_after_seconds: 5. |
All other statuses (401, 429, 403 tenant_suspended, 503
quota_unavailable) match Error envelope exactly.
List secret metadata
GET https://api.axiomide.com/api/v1/secretsReturns the authenticated tenant's secret names and state — never values:
[
{
"name": "openai-api-key",
"created_at": "2026-08-01T12:00:00Z",
"updated_at": "2026-08-10T09:30:00Z",
"armed": true,
"armed_expires_at": "2026-08-18T09:30:00Z"
}
]armed reports dev-arming: a secret that exists but is not armed will fail
development flow runs with reason dev_not_armed. This endpoint exists so a
CLI or agent can tell "secret missing" apart from "present but not armed"
without running a flow — it is what axiom secret ls calls. Secret VALUES
are written and read only from the Console in a browser; no API credential
class can reach them. App keys cannot call this endpoint (403 — a public
key must not read tenant metadata).
Client Builder management API
These routes create and manage clients
— tenant-scoped bundles of nodes and flows that compile into a typed SDK —
and back the axiom client CLI (see
Build a client SDK). They're a separate
management surface from the invocation endpoints above: served under the
/api path prefix on the same gateway (https://api.axiomide.com), not
/invocations.
POST https://api.axiomide.com/api/v1/clientsAuthentication is the same bearer API key as every other route. Mutating
calls (POST, PUT, DELETE) additionally require a key with the
write scope — a read-only-scoped key gets HTTP 403 with
{"error": "forbidden: token lacks 'write' scope"}. A request that reaches
the registry without a resolved tenant (for example, calling the registry
directly instead of through the gateway) gets HTTP 401 with
{"error": "missing tenant context"}.
| Method | Path | Description |
|---|---|---|
POST | /v1/clients | Create a client. |
GET | /v1/clients | List the tenant's clients. |
GET | /v1/clients/{id} | Get one client, including its members and versions. |
PUT | /v1/clients/{id} | Replace a client's name/description/default language. |
DELETE | /v1/clients/{id} | Delete a client (cascades to its members and versions). |
POST | /v1/clients/{id}/members | Add one member (a node or a flow). |
PUT | /v1/clients/{id}/members | Replace the full member set in one call. |
DELETE | /v1/clients/{id}/members/{memberId} | Remove one member. |
POST | /v1/clients/{id}/build | Start a build: a new version, or additional languages on an existing one. |
GET | /v1/clients/{id}/versions | List every version+language build row. |
GET | /v1/clients/{id}/versions/{version}?language={language} | Get one version+language build row. |
GET | /v1/clients/{id}/versions/{version}/download?language={language} | Download the built SDK zip. |
Create / get / list a client
curl -X POST 'https://api.axiomide.com/api/v1/clients' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name": "billing", "description": "Billing SDK", "default_language": "python"}'Request fields: name (string, required, unique per tenant — 409
{"error": "a client with that name already exists"} on collision),
description (string, optional), default_language (string, optional —
must be one of go, python, rust, java, typescript,
typescript-browser, csharp, else
400 {"error": "unsupported language: <value>"}). PUT takes the same
three fields to replace them wholesale.
Response (201 for create, 200 for get/list/update):
{
"id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
"tenant_id": "01J8Z3K9Q2R4S6T8V0W2X4Y700",
"name": "billing",
"slug": "billing",
"description": "Billing SDK",
"default_language": "python",
"latest_version": 0,
"created_at": "2026-07-04T12:00:00Z",
"updated_at": "2026-07-04T12:00:00Z",
"members": [],
"versions": [],
"member_count": 0,
"version_count": 0
}GET /v1/clients returns this same shape as a JSON array (never null,
even with zero clients); GET /v1/clients/{id} is the only call that
populates members/versions — the list form always reports them empty
and relies on member_count/version_count instead. GET /v1/clients/{id}
returns 404 {"error": "client not found"} for an unknown or
foreign-tenant ID.
Add / list / remove a member
curl -X POST 'https://api.axiomide.com/api/v1/clients/01J8Z3K9Q2R4S6T8V0W2X4Y6Z8/members' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"type": "node", "package": "acme/billing-utils", "version": "1.2.0", "node_name": "Summarize", "alias": "summarize"}'Request fields: type ("node" or "flow", required), and for
type: "node" — package (required), version (optional; empty or
"latest" pins the package's current latest version server-side),
node_name or node_ulid (one identifies the node); for type: "flow" —
artifact_id (required, the saved flow's compiled artifact ID). alias
is optional on the wire (derived from the node name if omitted) but must
be unique within the client — a collision returns 409
{"error": "a member with that alias already exists — pass a distinct alias"}.
PUT /v1/clients/{id}/members takes {"members": [...]} with the same
per-member shape and replaces the entire set transactionally.
Response (201 for add, 200 for PUT/list):
{
"id": "01J8Z3K9Q2R4S6T8V0W2X4Y700",
"client_id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
"member_type": "node",
"alias": "summarize",
"package_name": "acme/billing-utils",
"package_version": "1.2.0",
"node_ulid": "01J8Z3K9Q2R4S6T8V0W2X4Y701",
"created_at": "2026-07-04T12:00:00Z"
}package_name/package_version/node_ulid are omitted for a flow member,
which instead carries artifact_id. DELETE .../members/{memberId}
returns 204 No Content whether or not the member existed.
Build a version and check status
curl -X POST 'https://api.axiomide.com/api/v1/clients/01J8Z3K9Q2R4S6T8V0W2X4Y6Z8/build' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"languages": ["python", "go"]}'Request fields: languages (array of strings; falls back to the client's
default_language if omitted, deduplicated, each validated against the
same six-language set — 400 {"error": "unsupported language: <value>"}
for any unknown entry) and version (integer; 0 or omitted cuts a
new version from the client's current member set — 400
{"error": "client has no members — add at least one node or flow"} if it
has none; a positive value adds the given languages to that existing
version's frozen member snapshot instead — 404
{"error": "version not found"} if it doesn't resolve).
The call returns immediately with HTTP 202 and one row per requested
language, each starting in pending (build/upload happen asynchronously
in the background):
[
{
"id": "01J8Z3K9Q2R4S6T8V0W2X4Y702",
"client_id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
"version": 1,
"language": "python",
"status": "pending",
"created_at": "2026-07-04T12:00:00Z"
}
]Poll GET /v1/clients/{id}/versions (all rows) or
GET /v1/clients/{id}/versions/{version}?language={language} (one row —
the language query param is required, 400
{"error": "language query param is required"} without it) until
status reaches succeeded or failed; a failed row's error field
carries the failure message. No API key is ever embedded in a build (see
Call the SDK) — the
generated SDK always reads AXIOM_API_KEY from its caller's environment.
Download a built SDK
curl 'https://api.axiomide.com/api/v1/clients/01J8Z3K9Q2R4S6T8V0W2X4Y6Z8/versions/1/download?language=python' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-o billing-sdk-py.zipBoth version (path) and language (query, required) select the exact
build row. 200 OK streams Content-Type: application/zip with
Content-Disposition: attachment; filename="<client-slug>-sdk-<ext>.zip".
A row that hasn't reached succeeded returns 409
{"error": "build is not ready (status: <status>)"} instead of a body;
an unknown version/language pair returns 404
{"error": "version not found"}.
Instance creation API
This route materializes an Instance — a Generic node's
port(s) bound to a real type — entirely server-side (no git commit, no build,
no Knative deploy) and backs axiom instance create (see the
axiom-instance-authoring skill). Like the Client Builder routes above, it's
served under the /api path prefix, and mutating calls require a bearer
API key with the write scope.
POST https://api.axiomide.com/api/v1/instancesCreate an Instance
curl -X POST 'https://api.axiomide.com/api/v1/instances' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"generic_package": "acme/postgres-connector",
"generic_node": "Query",
"generic_version": "1.2.0",
"name": "your-handle/orders-query",
"version": "0.1.0",
"package_description": "Typed Postgres orders query",
"node_description": "Return an order summary by id",
"output": {
"message_name": "OrderSummary",
"fields": {"id": "int64", "total": "double", "status": "string"}
}
}'Request fields: generic_package / generic_node (required) locate the
Generic node to bind. generic_version is optional — empty or "latest"
resolves and pins whatever is latest at creation time (it does not track
"latest" going forward). name (required) is the Instance's scoped name,
your-handle/instance-name; the scope must equal the caller's own handle —
a mismatched scope gets 403. version (optional) is the author-chosen shell
package version (semver) — omitted defaults to 0.1.0 on a new package, and
an append inherits the existing package's version (a conflicting version
on an append is refused). package_description / node_description (optional
but recommended) set the marketplace description for the shell package and this
node — an Instance has no axiom.yaml, so without them it lists blank.
required_secrets (optional, e.g. ["OPENAI_API_KEY"]) declares the
tenant-secret NAMES this instance node needs at runtime — names only, never
values (ADR-170). Delivery is per-node and declaration-scoped: any invoking
tenant who has configured a value under that name gets it delivered to this
node at dispatch, regardless of who authored the package; the platform fails
closed at compile/invoke time if a declared name isn't configured. A
secret's value is set only by a human on the console's Secrets page — this
API never accepts or returns a secret value. See
Manage secrets in a flow. At
least one of output / input is required,
matching whichever port(s) the Generic node declared generic — each is
{"message_name": string, "description": string, "fields": {name: fieldDef}}
with at least one field. The optional description documents the facade
message as a whole.
A fieldDef value is either a bare scalar-kind string (as in the example
above — string, int32, int64, uint32, uint64, double, float, bool, bytes)
or an object for anything richer:
{"kind": "<scalar>", "repeated": true}, {"kind": "<scalar>", "optional": true} (mutually exclusive — proto3 forbids repeated optional), or
{"kind": "message", "message": {"message_name": "...", "fields": {...}}}
for a field whose type is itself another message, defined inline —
recursively, so that nested message's own fields may in turn include a
message-kind field.
Any object-form fieldDef — and any nested message — may also carry a
"description": that field's (or message's) human-readable documentation,
e.g. {"kind": "double", "description": "Order total in minor units, including tax."}. A description is documentation only. It never affects
the synthesized wire shape, and it is deliberately excluded from the
same-name shape check below, so two drafts of one message that differ only in
their descriptions still dedup rather than colliding (the first occurrence's
wording wins). Descriptions are rendered back out as leading // comments on
the synthesized proto source, which is what carries them into the derived
JSON Schema (GET /nodes/{id}/schema.json, a flow's openapi.json) and the
console's field tooltips. Each is folded to a single line and capped at 512
characters. Every message in the tree — the top-level facade and
every nested one, however deep — is synthesized in the same transaction that
creates the Instance: nothing is written if the request fails validation at
any level, and nothing exists as an intermediate row. See
Configure an Instance from a Generic node
for a full worked example.
Response (201) is the created Instance row:
{
"package_id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
"node_ulid": "01J8Z3K9Q2R4S6T8V0W2X4Y700",
"name": "your-handle/orders-query",
"version": "1",
"scope": "your-handle",
"owner_tenant_id": "01J8Z3K9Q2R4S6T8V0W2X4Y701",
"visibility": 0,
"generic_package": "acme/postgres-connector",
"generic_version": "1.2.0",
"generic_node_ulid": "01J8Z3K9Q2R4S6T8V0W2X4Y702",
"output_message": "OrderSummary"
}input_message is present instead of (or alongside) output_message when
the corresponding port was bound. Errors: 400 for a missing/malformed field
({"error": "generic_package and generic_node are required"}, "name is required", "at least one of output or input must be bound", an invalid
message/field identifier, a name collision in the draft tree, or binding a
node that isn't kind: generic), 403 if the scope doesn't match the
caller's handle, 409 {"error": "an instance with that name already exists"} on a duplicate name, and 413 {"error": "request body too large"} over the body-size cap below. Both an unknown Generic package
and one the caller can't read return the identical 404 {"error": "generic package not found"} — a denial can't be used as an existence oracle for
another tenant's private package.
Draft-tree limits
A request's output/input field tree — the top-level facade plus every
nested message — is bounded so a request fails fast with a clear error
instead of an unbounded compile:
| Limit | Bound |
|---|---|
| Nesting depth (top-level message counts as depth 1) | 10 |
| Distinct messages in one tree (both ports combined) | 100 |
| Fields on any one message | 200 |
| Request body size | 1 MiB |
A name reused for two different shapes within one request 400s as a
collision; the identical name and shape drafted twice (for example, the
same nested message referenced from both output and input) is a
harmless dedup, not an error. These same limits apply to a structured draft
sent to POST /adapters/preview below.
Preview an adapter mapping
POST /adapters/preview runs a field mapping (a single-edge adapter, or a
multi-source compose mapping) against sample JSON and returns the exact
value it produces — the same engine (PrepareAdapter/ExecuteAdapter, CEL,
transforms) the registry compiler and worker use at real compile/dispatch
time, not an approximation. It backs the canvas's live mapping preview and
axiom instance preview. Same auth posture as the Instance route above:
served under /api, bearer API key required.
POST https://api.axiomide.com/api/adapters/previewcurl -X POST 'https://api.axiomide.com/api/adapters/preview' \
-H "Authorization: Bearer $AXIOM_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"dst_msg_name": "OrderSummary",
"sources": [
{"edge_id": "e1", "msg_name": "Order", "sample": {"amount": 42.5}}
],
"adapter": {"total": "$.amount"}
}'Request fields: dst_msg_name (required) names the mapping's target
message. sources (required, at least one entry) is each incident edge's
edge_id + msg_name + sample sample JSON payload. Exactly one of
adapter (a single-edge DSL map, target field → expression) or
compose_mapping (edge_adapters + compose_plan, for a multi-source
compose consumer) is required; adapter requires exactly one sources
entry.
A compose preview must supply a sample for every incident edge its
compose_plan reads. A binding whose src_edge_id has no matching sources[]
entry is rejected by name rather than rendered as an absent field — preview
answers what the mapping produces, so a missing sample is a question it cannot
answer honestly. Preview also applies the same repeated→singular pick rule
POST /v1/graphs/compile enforces, so a mapping that previews clean compiles.
Any msg_name slot — dst_msg_name or a sources[] entry — accepts
either a bare registry message name (a JSON string, as above) or an
inline structured draft in place of it: the identical
{"message_name": ..., "fields": {...}} tree shape the Instance-creation
fields block uses (including nested message-kind fields and
repeated/optional modifiers). A draft is rendered into .proto text and
compiled transiently for that one request only — never persisted — so a
mapping can be previewed against a message that doesn't exist in the
registry yet:
{
"dst_msg_name": {"message_name": "OrderSummary", "fields": {"total": "double"}},
"sources": [{"edge_id": "e1", "msg_name": "Order", "sample": {"amount": 42.5}}],
"adapter": {"total": "$.amount"}
}Response (200) is always {"output": <json>} or {"error": "<message>"},
never a 4xx for a mapping-level failure (bad expression, type mismatch,
unresolvable field) — that's a property of the in-progress mapping, not a
malformed request:
{"output": {"total": 42.5}}A malformed request body, an unresolvable msg_name not found in the
registry, or exceeding the draft-tree limits above is
a 4xx with a plain-text body, not the {"error": ...} JSON envelope other
routes on this page use.
Error envelope
Invocation failures with a known cause return a structured body with a
stable, machine-readable error class:
{
"error": "payload_too_large",
"message": "payload exceeds hard cap: actual=18874368 max=16777216",
"max_bytes": 16777216,
"actual_bytes": 18874368
}| Status | error class | Extra fields | When |
|---|---|---|---|
| 400 | — | {"accepted": false, "error_message": "invalid request body: ..."} | The request body is not valid JSON. |
| 401 | unauthorized | — | Missing, invalid, or revoked API key. |
| 413 | payload_too_large | max_bytes, actual_bytes (when the rejecting code path knows the sizes) | The input payload exceeds the platform's 16 MiB hard cap. |
| 422 | — | {"error_message": "..."} | Single-node invocation only: the node itself returned an error. |
| 429 | rate limit exceeded | Retry-After: 1 header | Per-tenant rate limit exceeded. |
| 429 | quota_exceeded | retry_after_seconds to next UTC midnight, mirrored in the Retry-After header | Per-tenant daily quota (invocations or executions) exhausted. |
| 403 | tenant_suspended | — | Your tenant has been disabled by the operator. |
| 503 | quota_unavailable | retry_after_seconds: 5 | The quota service is unreachable; the request is rejected as a safety measure. Retry with backoff. |
| 503 | upstream_unavailable | retry_after_seconds: 5 | The flow could not be queued; a platform dependency was temporarily unavailable. Retry with backoff. |
| 503 | blob_storage_unavailable | retry_after_seconds: 5 | A large input payload could not be stored; a platform dependency was temporarily unavailable. Retry with backoff. |
| 500 | — | {"accepted": false, "error_message": "..."} | Any failure that does not classify into the rows above (for example, a missing graph_id). |
Errors inside a successful HTTP exchange surface differently: a waited
invoke returns 202 with result.success: false, and a stream delivers a
final frame with error set and no success: true (the frame's success
field is omitted on failure, never serialized as false). The
error catalog lists the error messages
themselves.
Live OpenAPI specs
Hand-maintained docs cannot know your flow's input schema — the live, generated OpenAPI 3.0 specs can. Use them as the authoritative request and response schemas:
- Per flow:
GET /api/graphs/{artifact_id}/openapi.json(authenticated, same bearer key). Generated on demand from the compiled artifact: the entry node's input message is the documented input schema, the terminal node's output message is the result schema, andgraph_idis pinned to that exact artifact. Pipeline-mode flows document/v1/flows/invoke/stream; unary flows document/v1/flows/invoke. The same spec backs the Open interactive docs button in the flow inspector's API section — see Use the interactive API docs. - Per package:
GET /api/packages/{name}@{version}/openapi.json(public, no auth). OnePOST /v1/nodes/{owner}/{package}/{version}/{node}operation per node, with input and output schemas and examples. An interactive try-it page for the same spec is served atGET /api/packages/{name}@{version}/docs.