Public beta — not for production use. Data may be wiped at any time. Questions? Contact us.
Documentation menu

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 Markdown

This 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/invoke

Every 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 10

or 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 restriction

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

StatusBody errorMeaning
403tenant_suspendedYour tenant has been disabled by the operator. No invocations or resumes are accepted. Contact the operator.
503quota_unavailableThe 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

FieldTypeRequiredMeaning
graph_idstringyesThe 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.
inputobjectone of input/payloadThe entry node's input message as a JSON object. The platform converts it to the typed message, and decodes the result back to JSON.
payloadstring (base64)one of input/payloadThe entry node's input message as protobuf-encoded bytes, base64-encoded. For callers that already speak protobuf. input takes precedence if both are set.
waitbooleannotrue blocks until the execution completes and populates result. Default false: the response returns immediately with just accepted and execution_id.
timeout_secondsintegernoHow long a waiting call blocks before giving up (default 30).
config_idstringnoNamed flow config profile to apply; when omitted the default hierarchy applies (tenant default → flow default). See Flow configs.
debug_session_idstringnoStreams 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_keystringnoDedups 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 with wait: true. result.output is the terminal node's output message decoded to JSON (when you invoked with input); result.payload carries base64-encoded protobuf bytes instead when you invoked with payload or when JSON decoding was not possible.

  • result.error_message — present with wait: true when the execution was accepted and delivered but ran to a failure (result.success is false), for example because the input did not match the flow's start-node schema. The same diagnostic is also copied to the top-level error_message so a caller can read it without descending into result. 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 the wait: true accepted-with-no-result path, naming which non-terminal outcome you got: PAUSED (a human-in-the-loop step is waiting) versus RUNNING (your wait simply elapsed first). Empty means the status could not be determined — treat that as still running.

  • pause_id — accompanies execution_status: "PAUSED": the waiting pause, ready to pass straight to the resume endpoint. For pauses discovered later (or after a reconnect), list them with GET /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 with axiom flow compile --no-mocks before 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:

FieldMeaning
execution_idSame ID as the unary endpoint — also the trace ID.
frame_indexPosition of this frame in the stream, starting at 0.
payloadThe terminal node's output message for this frame, decoded to JSON. Omitted when the frame carries no decodable payload.
is_finaltrue on the last frame; the stream ends after it.
successtrue 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.
errorError 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) or POST /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:

FormTransportDurable?Use it for
Split shape3 half-duplex HTTP requestsYes — replay from offset, reconciled frame countBulk or log-shaped ingestion where losing nothing matters
Live mode1 WebSocket, both directions at onceNo — ephemeral, no execution row, no replayInteractive sessions: token relays, live tails, dialogue
Single-call1 HTTP request, buffered responseYesSmall 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:

LegRequestReturns
1. CreatePOST /v1/nodes/streams with {"node_ref": "..."}201 {"stream_id", "input_url", "output_url", "expires_in"}
2. OutputGET /v1/nodes/streams/{id}/outputServer-Sent Events, one frame per event
3. InputPOST /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_frames in 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, carrying execution_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's execution_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:

BoundValueWhat it protects
Per input frame16 MiBOne oversized message
Per session OUTPUT64 MiBA runaway node on one socket
Wall clock5 minutesThe 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 error event 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

FieldTypeRequiredMeaning
pause_idstringyesWhich pause to resume. An execution may hold several sequential pauses.
valueJSONnoThe decision. Becomes the paused node's input payload.
resumed_bystringnoRecorded 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, not resume_value. The response (and the pause rows below) echo the decision back as resume_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 if value were omitted. Write value in the request; expect resume_value everywhere it is read back.

Errors

StatusCodeMeaning
400INVALID_ARGUMENTMissing pause_id, or an unparseable body.
400INVALID_RESUME_VALUEvalue failed the pause's schema check or exceeded 16 MiB.
400PAUSE_EXECUTION_MISMATCHThe pause belongs to a different execution.
404PAUSE_NOT_FOUND / EXECUTION_NOT_FOUND / CHECKPOINT_NOT_FOUNDNo such pause, execution, or stored checkpoint.
409PAUSE_NOT_WAITINGThe pause already expired or was cancelled.
409EXECUTION_NOT_RESUMABLEThe execution reached a terminal status; its pauses can no longer be resumed.
503UNAVAILABLE / SECRETS_UNAVAILABLEA 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"
}
  • status is one of QUEUED | RUNNING | COMPLETED | FAILED | CANCELLED | FORKED. completed_at is 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 invoke on a single node) have node_id / node_fqn set and no graph_id. Fire-and-forget live invocations (replay: false streams) 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}
    }
  ]
}
  • status is one of WAITING | RESUMED | EXPIRED | CANCELLED. An execution holds at most one WAITING pause at a time; sequential pauses are normal for multi-turn flows.
  • A WAITING row's pause_id is what the resume endpoint takes.
  • resume_value is 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:

  1. Page GET /v1/executions/{id}/events until next_cursor is absent, recording each event's event_ulid.
  2. Open the WebSocket. After the SESSION_READY handshake frame it replays the buffered head, then goes live.
  3. Drop any socket event whose event_ulid you 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:

FieldMeaning
typeEvent type (table below).
event_ulidUnique, time-ordered id — the dedupe key across pull + live.
execution_id / session_idWhich run this event belongs to.
timestamp_msPublish time, epoch milliseconds.
node_id, node_name, instance_idThe placement the event pertains to, where applicable.
input_bytes, output_bytes, frame_bytesBase64 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.

TypeEmitted
SESSION_READYWebSocket handshake only — not a stored event, no event_ulid.
NODE_STARTED / NODE_COMPLETED / NODE_FAILEDNode lifecycle. NODE_COMPLETED carries output_bytes; mocked: true marks an authoring-fixture response.
NODE_LOGA log line from node code (log_message, log_level).
NODE_PROGRESSOne 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_EVALUATEDPer-edge condition/adapter outcome (condition_pass, adapter_applied, error).
NODE_FRAME / NODE_INPUT_FRAME / NODE_STREAM_COMPLETEPipeline-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_EXHAUSTEDDurable retry lifecycle (attempt, max_attempts, next_attempt_at_ms).
HITL_PAUSED / HITL_RESUMED / HITL_RESUME_FAILED / HITL_TIMEOUT / ESCALATION_TRIGGEREDHuman-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_FAILEDTerminal. FLOW_FAILED may carry a structured failure_payload.
GRAPH_MUTATEDA mutation-capable node grew the running graph.
MEMORY_READ / MEMORY_WRITEMemory-layer activity. Live socket only — not persisted.

Pull endpoint parameters

ParamMeaning
cursorOpaque cursor from the previous page's next_cursor.
limitPage size, default 100, max 1000.
node_nameOnly events for this node.
event_typeRepeatable; only these event types.
decodetrue 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

FieldTypeRequiredMeaning
graph_idstringyes for API keys; optional for app keysThe 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:

Statuserror classWhen
429warm_rate_limitedThis 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

Statuserror classWhen
400invalid_requestgraph_id is missing or doesn't match [A-Za-z0-9_-]+.
403forbiddenThe authenticated tenant does not own graph_id.
404not_foundgraph_id doesn't resolve to any flow.
503warm_unavailableThe 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/secrets

Returns 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/clients

Authentication 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"}.

MethodPathDescription
POST/v1/clientsCreate a client.
GET/v1/clientsList 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}/membersAdd one member (a node or a flow).
PUT/v1/clients/{id}/membersReplace the full member set in one call.
DELETE/v1/clients/{id}/members/{memberId}Remove one member.
POST/v1/clients/{id}/buildStart a build: a new version, or additional languages on an existing one.
GET/v1/clients/{id}/versionsList 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.zip

Both 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/instances

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

LimitBound
Nesting depth (top-level message counts as depth 1)10
Distinct messages in one tree (both ports combined)100
Fields on any one message200
Request body size1 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/preview
curl -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
}
Statuserror classExtra fieldsWhen
400{"accepted": false, "error_message": "invalid request body: ..."}The request body is not valid JSON.
401unauthorizedMissing, invalid, or revoked API key.
413payload_too_largemax_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.
429rate limit exceededRetry-After: 1 headerPer-tenant rate limit exceeded.
429quota_exceededretry_after_seconds to next UTC midnight, mirrored in the Retry-After headerPer-tenant daily quota (invocations or executions) exhausted.
403tenant_suspendedYour tenant has been disabled by the operator.
503quota_unavailableretry_after_seconds: 5The quota service is unreachable; the request is rejected as a safety measure. Retry with backoff.
503upstream_unavailableretry_after_seconds: 5The flow could not be queued; a platform dependency was temporarily unavailable. Retry with backoff.
503blob_storage_unavailableretry_after_seconds: 5A 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, and graph_id is 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). One POST /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 at GET /api/packages/{name}@{version}/docs.