Source: https://docs.cumuluslabs.io/api
Content version: 753c978a

# 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 {#quickstart}

### 1\. Get an API key {#api-key}

In [Talos → Settings → API keys](https://app.cumuluslabs.io/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 forbidden` on every command. Each operation's required role is in the tables on these pages and in the [API reference](/docs/api-reference).

### 2\. Call the API {#first-call}

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.

<!-- code-tabs: List agents -->


**List agents · curl**

```curl
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"
```

**List agents · typescript**

```typescript
// @cumulus/sdk: see SDKs. ESM, Node.js 20.3 or newer.
import { Cumulus } from "@cumulus/sdk";

const client = new Cumulus(); // reads CUMULUS_API_KEY
const page = await client.agents.list({ query: { limit: 20 } });
for (const agent of page.agents) console.log(agent.ref.id, agent.name, agent.head?.version ?? "not published");
```

**List agents · python**

```python
# cumulus-sdk: see SDKs. Python 3.10 or newer.
from cumulus import Cumulus

client = Cumulus()  # reads CUMULUS_API_KEY
page = client.agents.list(query={"limit": 20})
for agent in page.agents:
    print(agent.ref.id, agent.name, agent.head.version if agent.head else "not published")
```




<!-- /code-tabs -->

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 {#idempotency}

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 `200` instead of `201`. Nothing is created twice.
*   **Same key, different body:** `409 idempotency_conflict`.
*   **No key:** `400 invalid_request`. Keys are 1 to 256 characters; `agents.create` and `agents.clone` need 8 to 255 visible ASCII characters. A UUID satisfies both.
*   **API keys are the exception:** a replayed `keys.create` or `keys.rotate` answers `409 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 {#errors}

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.reason` says why, for example `capacity_exhausted`, `spend_cap_exhausted`, `trunk_not_authorized` or `missing_dynamic_variables`. Validation errors list `details.issues`; a draft that does not compile lists `details.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 |

<!-- code-tabs: Retry safely -->


**Retry safely · curl**

```curl
# 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}'
fi
```

**Retry safely · typescript**

```typescript
import { Cumulus, PlatformError } from "@cumulus/sdk";

const body = { channel: "cloud" as const, agent_id: agentId, mode: "task" as const, input: {} };
try {
  await client.runs.create({ body });
} catch (error) {
  // outcome "unknown": the run may exist. Retry with the key the SDK sent, never a new one.
  if (!(error instanceof PlatformError) || error.outcome !== "unknown") throw error;
  await client.runs.create({ body, idempotencyKey: error.idempotencyKey });
}
```

**Retry safely · python**

```python
from cumulus import PlatformError

body = {"channel": "cloud", "agent_id": agent_id, "mode": "task", "input": {}}
try:
    client.runs.create(body=body)
except PlatformError as error:
    # outcome "unknown": the run may exist. Retry with the key the SDK sent, never a new one.
    if error.outcome != "unknown":
        raise
    client.runs.create(body=body, idempotency_key=error.idempotency_key)
```




<!-- /code-tabs -->

## Pagination {#pagination}

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

<!-- code-tabs: Page through runs -->


**Page through runs · curl**

```curl
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")
done
```

**Page through runs · typescript**

```typescript
let cursor: string | undefined;
do {
  const page = await client.runs.list({ query: { limit: 200, cursor } });
  for (const run of page.items) console.log(run.run_id, run.state);
  cursor = page.next_cursor ?? undefined;
} while (cursor);
```

**Page through runs · python**

```python
cursor = None
while True:
    page = client.runs.list(query={"limit": 200, "cursor": cursor})
    for run in page.items:
        print(run.run_id, run.state.value)
    cursor = page.next_cursor
    if not cursor:
        break
```




<!-- /code-tabs -->

## Create and publish an agent {#publish-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](/docs/agents) for every template, edit and the flow graph.

<!-- code-tabs: Create and publish an agent -->


**Create and publish an agent · curl**

```curl
# 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}'
```

**Create and publish an agent · typescript**

```typescript
const agent = await client.agents.create({
  body: { name: "Ticket triage", template: "cloud_task", channels: ["cloud"], languages: ["en"] },
});
const draft = agent.draft!; // a new agent is its first draft
const edited = await client.drafts.edit({
  path: { agent_id: agent.ref.id },
  body: {
    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." }],
  },
});
const publication = await client.publications.publish({
  path: { agent_id: agent.ref.id },
  body: { draft_id: edited.draft_id, expected_revision: edited.revision, version: 0 },
});
console.log(publication.ref.agent_id, publication.ref.version, publication.is_head);
```

**Create and publish an agent · python**

```python
agent = client.agents.create(
    body={"name": "Ticket triage", "template": "cloud_task", "channels": ["cloud"], "languages": ["en"]}
)
draft = agent.draft  # a new agent is its first draft
edited = client.drafts.edit(
    path={"agent_id": agent.ref.id},
    body={
        "draft_id": str(draft.draft_id),
        "expected_revision": draft.revision,
        "edits": [{"type": "flow.prompt", "text": "Classify the support ticket in the input as billing, technical or other."}],
    },
)
publication = client.publications.publish(
    path={"agent_id": agent.ref.id},
    body={"draft_id": str(edited.draft_id), "expected_revision": edited.revision, "version": 0},
)
print(publication.ref.agent_id, publication.ref.version, publication.is_head)
```




<!-- /code-tabs -->

## Start a cloud run {#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.

<!-- code-tabs: Start a cloud run -->


**Start a cloud run · curl**

```curl
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}'
```

**Start a cloud run · typescript**

```typescript
const run = await client.runs.create({
  body: { channel: "cloud", agent_id: agent.ref.id, mode: "task", input: { ticket: "I was charged twice this month." } },
});
console.log(run.run_id, run.state, run.version);
```

**Start a cloud run · python**

```python
run = client.runs.create(
    body={"channel": "cloud", "agent_id": agent.ref.id, "mode": "task", "input": {"ticket": "I was charged twice this month."}}
)
print(run.run_id, run.state.value, run.version)
```




<!-- /code-tabs -->

## Follow a run live {#events}

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

<!-- code-tabs: Follow a run -->


**Follow a run · curl**

```curl
# 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"
```

**Follow a run · typescript**

```typescript
let last: string | undefined;
for await (const event of client.events({ path: { run_id: run.run_id } })) {
  last = String(event.sequence); // resume later with client.events({ path, lastEventId: last })
  console.log(event.sequence, event.type);
  if (event.type === "run.ended") break;
}
```

**Follow a run · python**

```python
last = None
for event in client.events(str(run.run_id)):
    payload = event.model_dump(mode="json")
    last = str(payload["sequence"])  # resume later with client.events(run_id, last_event_id=last)
    print(payload["sequence"], payload["type"])
    if payload["type"] == "run.ended":
        break
```




<!-- /code-tabs -->

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 {#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](/docs/phone).

<!-- code-tabs: Place a phone call -->


**Place a phone call · curl**

```curl
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}'
```

**Place a phone call · typescript**

```typescript
const call = await client.runs.create({
  body: {
    channel: "phone",
    agent_id: phoneAgentId,
    to: "+14155550123",
    from: "+14155550100", // a number imported on one of your trunks
    dynamic_variables: { customer_name: "Ada", balance: "120.00" },
    metadata: { crm_id: "A-1001" },
  },
});
console.log(call.run_id, call.state, call.phone_state);
```

**Place a phone call · python**

```python
call = client.runs.create(
    body={
        "channel": "phone",
        "agent_id": phone_agent_id,
        "to": "+14155550123",
        "from": "+14155550100",  # a number imported on one of your trunks
        "dynamic_variables": {"customer_name": "Ada", "balance": "120.00"},
        "metadata": {"crm_id": "A-1001"},
    }
)
print(call.run_id, call.state.value, call.phone_state.value if call.phone_state else None)
```




<!-- /code-tabs -->

`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](/docs/runs#dynamic-variables) shows how to list what an agent needs.

## Read the transcript, recording and analysis {#outputs}

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

<!-- code-tabs: Read run outputs -->


**Read run outputs · curl**

```curl
# 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}'
```

**Read run outputs · typescript**

```typescript
const path = { run_id: run.run_id };
const result = await client.runs.result({ path });
console.log(result.state, result.result);
const transcript = await client.runs.transcript({ path });
for (const turn of transcript.utterances) console.log(`${turn.role}: ${turn.text}`);
const analysis = await client.runs.analysis({ path });
console.log(analysis.state, analysis.disposition, analysis.successful, analysis.fields);
// Phone and browser runs only (a cloud run has no recording):
// const recording = await client.runs.recording({ path }); recording.url expires at recording.url_expires_at
```

**Read run outputs · python**

```python
path = {"run_id": str(run.run_id)}
result = client.runs.result(path=path)
print(result.state.value, result.result)
transcript = client.runs.transcript(path=path)
for turn in transcript.utterances:
    print(f"{turn.role.value}: {turn.text}")
analysis = client.runs.analysis(path=path)
print(analysis.state.value, analysis.disposition, analysis.successful, analysis.fields)
# Phone and browser runs only (a cloud run has no recording):
# recording = client.runs.recording(path=path); recording.url expires at recording.url_expires_at
```




<!-- /code-tabs -->

## Limits {#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/capacity` shows each channel's limit and active runs.
*   **Budget:** once metered spend reaches the organization's cap, new runs get `429` with `spend_cap_exhausted`.
*   **Request rate:** API key and secret operations are rate limited per organization; beyond the limit they answer `429` with a `Retry-After` header.
