On this page
Using the API
Everything in Talos is an HTTPS operation on one host. This page takes you from an API key to a published agent, a cloud run you follow live and a phone call, with each step in curl, TypeScript and Python.
Quickstart
1. Get an API key
In Talos → Settings → API keys, name the key after the system that will use it, pick its role and create it. The secret (cmls_…) is shown once: store it in your secret manager. Only organization admins manage keys; an admin can also create them with POST /api/v1/keys.
| Key role | Can | Use it for |
|---|---|---|
| member | Read everything; create and edit agents; publish; start, update and cancel runs. | Starting runs and calls (the default role) |
| admin | Everything a member can, plus API keys, secrets, SIP trunks, phone numbers and the budget. | Setup and automation that manages the organization |
| viewer | Read only. | Dashboards and exports |
Runs need a member or admin key
A viewer key gets
403 forbiddenon every command. Each operation's required role is in the tables on these pages and in the API reference.
2. Call the API
The base URL is https://api.cumuluslabs.io for every operation. Send the key as a bearer token in the Authorization header. Request and response bodies are JSON.
export CUMULUS_API_KEY=cmls_... # a member or admin key
curl -sS "https://api.cumuluslabs.io/api/v1/agents?limit=20" \
-H "Authorization: Bearer $CUMULUS_API_KEY"A 401 unauthenticated means the key is missing, revoked or expired. The SDKs read CUMULUS_API_KEY and default to this base URL; CUMULUS_BASE_URL overrides it.
Idempotency-Key
Commands that create something take an Idempotency-Key header, so a retry never creates a second run, agent or key. Send a new random value (a UUID) for each logical request and reuse it for every retry of that request.
- Same key, same body: you get the original resource back, with
200instead of201. Nothing is created twice. - Same key, different body:
409 idempotency_conflict. - No key:
400 invalid_request. Keys are 1 to 256 characters;agents.createandagents.cloneneed 8 to 255 visible ASCII characters. A UUID satisfies both. - API keys are the exception: a replayed
keys.createorkeys.rotateanswers409 conflict(details.reason: "secret_returned_once"), because a key's secret is only ever returned once.
The SDKs generate a key for you when you pass none, and report it on every error. These operations take one:
POST``agents.create``POST``agents.clone``POST``agents.import``POST``runs.create``PATCH``runs.update``POST``runs.send_message``POST``test_sessions.create``POST``trunks.create``POST``numbers.import``PUT``secrets.put``POST``test_suites.create``POST``test_runs.create``POST``keys.create``POST``keys.rotate
The other commands are idempotent by what they name: publishing names the version, draft edits name the revision they expect, and deleting twice deletes once.
Errors and retries
Every failure has the same body. code is stable; branch on it, not on the message.
Error body
{
"error": {
"code": "rate_limited",
"message": "Tenant concurrent call limit reached",
"retryable": true,
"outcome": "none",
"details": { "reason": "capacity_exhausted" }
}
}
retryable: the same request can succeed later (back off first).outcome:"none"means nothing happened."unknown"means the command may have taken effect: retry it with the same Idempotency-Key, never a new one.details.reasonsays why, for examplecapacity_exhausted,spend_cap_exhausted,trunk_not_authorizedormissing_dynamic_variables. Validation errors listdetails.issues; a draft that does not compile listsdetails.diagnostics.
| code | HTTP | retryable | outcome |
|---|---|---|---|
| unauthenticated | 401 | no | none |
| forbidden | 403 | no | none |
| not_found | 404 | no | none |
| conflict | 409 | no | none |
| invalid_request | 400 | no | none |
| idempotency_conflict | 409 | no | none |
| policy_denied | 403 | no | none |
| capability_unavailable | 409 | no | none |
| provider_unavailable | 503 | yes | none |
| knowledge_unavailable | 409 | yes | none |
| publication_invalid | 422 | no | none |
| run_not_active | 409 | no | none |
| rate_limited | 429 | yes | none |
| outcome_unknown | 502 | yes | unknown |
| internal | 500 | yes | none |
# Admits a run, retrying transient failures with the SAME key. Prints the run, or the error and returns 1.
create_run() { # $1: request body, $2: Idempotency-Key (keep it until you know the outcome)
local code attempt
for attempt in 1 2 3; do
code=$(curl -sS -o run.json -w '%{http_code}' -X POST https://api.cumuluslabs.io/api/v1/runs \
-H "Authorization: Bearer $CUMULUS_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $2" -d "$1") || code=000
case $code in
200|201) cat run.json; return 0 ;; # 201 created, 200 replayed
000|429|5??) sleep $((attempt * 2)) ;; # no answer, or retryable: try again with the same key
*) jq . run.json >&2; return 1 ;; # not retryable: read error.code
esac
done
echo "No run confirmed after 3 attempts (HTTP $code); retry later with Idempotency-Key $2" >&2
return 1
}
if run=$(create_run "$(jq -n --arg agent "$AGENT_ID" '{channel: "cloud", agent_id: $agent, mode: "task", input: {}}')" "$(uuidgen)"); then
echo "$run" | jq '{run_id, state}'
fiPagination
List operations take limit (1 to 200, default 50) and cursor. A page carries next_cursor; pass it back as cursor for the next page, until it is null. Run events page by sequence instead (after_sequence and next_after_sequence).
args=(--data-urlencode "limit=200")
while :; do
page=$(curl -sS --fail-with-body -G "https://api.cumuluslabs.io/api/v1/runs" \
-H "Authorization: Bearer $CUMULUS_API_KEY" "${args[@]}") || { echo "request failed: $page" >&2; break; }
echo "$page" | jq -r '.items[] | "\(.run_id) \(.state)"'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -n "$cursor" ] || break
args=(--data-urlencode "limit=200" --data-urlencode "cursor=$cursor")
doneCreate and publish an agent
An agent starts as a draft. Edit it, then publish it as version 0; later publishes take the next version. The cloud_task template takes a JSON input, works through it and ends. See Agents for every template, edit and the flow graph.
# 1. Create the agent from a template. It exists as its first draft.
created=$(curl -sS -X POST https://api.cumuluslabs.io/api/v1/agents \
-H "Authorization: Bearer $CUMULUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"name": "Ticket triage", "template": "cloud_task", "channels": ["cloud"], "languages": ["en"]}')
AGENT_ID=$(echo "$created" | jq -r .ref.id)
# 2. Edit the draft: set the agent's prompt. expected_revision guards against concurrent edits.
edited=$(curl -sS -X PATCH "https://api.cumuluslabs.io/api/v1/agents/$AGENT_ID/draft" \
-H "Authorization: Bearer $CUMULUS_API_KEY" \
-H "Content-Type: application/json" \
-d "$(echo "$created" | jq '{draft_id: .draft.draft_id, expected_revision: .draft.revision,
edits: [{type: "flow.prompt", text: "Classify the support ticket in the input as billing, technical or other."}]}')")
# 3. Publish the draft as version 0. It becomes the head: runs that name no version use it.
curl -sS -X POST "https://api.cumuluslabs.io/api/v1/agents/$AGENT_ID/publications" \
-H "Authorization: Bearer $CUMULUS_API_KEY" \
-H "Content-Type: application/json" \
-d "$(echo "$edited" | jq '{draft_id, expected_revision: .revision, version: 0}')" | jq '.error // {ref, is_head}'Start a cloud run
POST /api/v1/runs admits a run of the agent's head (or of version, when you name one) and answers at once with the run in state admitted. mode is task or chat; input must match the publication's input schema. Add "test": true for a test run.
run=$(curl -sS -X POST https://api.cumuluslabs.io/api/v1/runs \
-H "Authorization: Bearer $CUMULUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "$(jq -n --arg agent "$AGENT_ID" \
'{channel: "cloud", agent_id: $agent, mode: "task", input: {ticket: "I was charged twice this month."}}')")
RUN_ID=$(echo "$run" | jq -r .run_id)
echo "$run" | jq '.error // {run_id, state, version}'Follow a run live
GET /api/v1/runs/{run_id}/events with Accept: text/event-stream streams the run's semantic events as server-sent events, from the first. Each frame's id is the event's sequence (1, 2, 3, … per run), event is its type and data is the event as JSON. A comment line keeps the connection open every 25 seconds. The stream ends once the run has ended and its effects and webhooks have settled.
text/event-stream
id: 1
event: run.admitted
data: {"event_id":"…","type":"run.admitted","sequence":1,"run":{"run_id":"…","publication":{…}},…}
id: 2
event: run.state_changed
data: {"event_id":"…","type":"run.state_changed","sequence":2,…}
: keepalive
To resume after a disconnect, send Last-Event-ID with the last sequence you processed (or the query parameter after_sequence). Without the SSE Accept header the same operation returns a JSON page:{ items, next_after_sequence }, up to limit events (1 to 500, default 100).
# Server-sent events. The stream ends after the run has ended and its effects have settled.
curl -sS -N "https://api.cumuluslabs.io/api/v1/runs/$RUN_ID/events" \
-H "Authorization: Bearer $CUMULUS_API_KEY" \
-H "Accept: text/event-stream"
# Reconnect where you left off: send the id of the last event you processed.
curl -sS -N "https://api.cumuluslabs.io/api/v1/runs/$RUN_ID/events" \
-H "Authorization: Bearer $CUMULUS_API_KEY" \
-H "Accept: text/event-stream" \
-H "Last-Event-ID: 3"A run's events have these types:
run.admitted``run.state_changed``run.attempt_started``run.attempt_ended``run.node_entered``run.message``transcript.updated``run.ended``run.degraded``run.turn_events``phone.sip_received``phone.amd_verdict``phone.voicemail``phone.transfer``action.requested``action.decided``effect.sent``effect.settled``knowledge.retrieved``recording.updated``analysis.updated
Place a phone call
A phone run dials to from from, both in E.164 form. from is a number you imported on one of your SIP trunks; leave it out to use the caller ID the agent was published with. dynamic_variables fill the agent's {{variables}} for this call. Numbers and trunks are set up once, by an admin: see Phone.
curl -sS -X POST https://api.cumuluslabs.io/api/v1/runs \
-H "Authorization: Bearer $CUMULUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "$(jq -n --arg agent "$PHONE_AGENT_ID" '{
channel: "phone", agent_id: $agent,
to: "+14155550123", from: "+14155550100",
dynamic_variables: {customer_name: "Ada", balance: "120.00"},
metadata: {crm_id: "A-1001"}
}')" | jq '.error // {run_id, state, phone_state, to, from}'phone_state follows the call (dialing, ringing, active, ended, …) while the run state stays channel-neutral. PATCH /api/v1/runs/{run_id} updates a live call's dynamic variables.
Send every variable the agent uses
If the agent's flow uses a
{{variable}}you did not send, the call is refused before it dials:400 invalid_request,details.reason: "missing_dynamic_variables", and the message names each missing one. Dynamic variables shows how to list what an agent needs.
Read the transcript, recording and analysis
Once a run has ended, read what it produced. Each read has a state, so you can tell "not yet" (pending, processing) from "never" (none, not_available, not_requested) and from "removed by retention" (expired).
# What a cloud task produced (validated against the publication's output schema)
curl -sS "https://api.cumuluslabs.io/api/v1/runs/$RUN_ID/result" -H "Authorization: Bearer $CUMULUS_API_KEY" | jq '.error // {state, result}'
# The conversation, turn by turn
curl -sS "https://api.cumuluslabs.io/api/v1/runs/$RUN_ID/transcript" -H "Authorization: Bearer $CUMULUS_API_KEY" \
| jq -r '.error // (.utterances[] | "\(.role): \(.text)")'
# Post-run analysis: disposition, success and the fields the agent extracts
curl -sS "https://api.cumuluslabs.io/api/v1/runs/$RUN_ID/analysis" -H "Authorization: Bearer $CUMULUS_API_KEY" \
| jq '.error // {state, disposition, successful, fields}'
# Phone and browser runs only: a recording URL that expires after five minutes
curl -sS "https://api.cumuluslabs.io/api/v1/runs/$RUN_ID/recording" -H "Authorization: Bearer $CUMULUS_API_KEY" \
| jq '.error // {state, url, url_expires_at}'Limits
- Concurrent runs per channel are capped for your organization. A run beyond the cap is refused with
429 rate_limited(details.reason: "capacity_exhausted") and nothing is created.GET /api/v1/runs/capacityshows each channel's limit and active runs. - Budget: once metered spend reaches the organization's cap, new runs get
429withspend_cap_exhausted. - Request rate: API key and secret operations are rate limited per organization; beyond the limit they answer
429with aRetry-Afterheader.