Caldera docs
calderaapp.io

The agent protocol

How an agent talks to Caldera. This page is the normative description of protocol version 1: if you write your own reporter, this is what you build against. It is also the API of Caldera, because Caldera has no public REST API for scripts.

The shape in one paragraph

An agent reports itself, at a fixed interval (30 seconds by default), over HTTPS, outwards. Caldera never connects to an agent's machine. Commands (pause, run now, change a setting, send a log) travel back in the answer to a report; the agent checks each one against its own fixed limits, applies it or refuses it, and says which in a later report. Nothing on the agent's machine listens.

Rules for a reporter

These are what make an agent independent of Caldera. A reporter that breaks one of them is wrong even if everything works.

  1. The reporter is not in the agent's control path. The agent starts runs, picks its work and stops on its own. It never waits for Caldera, never asks Caldera for permission, and never takes work from it.
  2. The reporter fails on its own. Run it as a separate process (a systemd user service, a sidecar). Use a timeout on every request. When Caldera is unreachable, slow or answers nonsense, log it, wait for the next interval and try again; nothing else changes.
  3. The agent's state lives on the agent's machine. Pause, limits, schedule and settings are the agent's own files. What Caldera shows is a copy of the last report.
  4. Everything Caldera can do is reachable and reversible without it. A pause set from Caldera is the same pause the agent's own channel sets and clears.
  5. A command is a wish. Check it against the agent's own hard limits, apply it or refuse it, and answer. A refusal is a normal answer.
  6. Send what the page should show, nothing more. State, figures, titles. Content of private data (mails, messages) stays with the agent. Logs are the one exception, and the agent decides which logs it hands out.

Authentication

Authorization: Bearer caldera_agt_…

A token belongs to exactly one agent. An admin creates the agent in Caldera (or rotates its token) and sees the token once. It reaches the report and log routes below for that agent, and, for an agent installed from a blueprint, the bundle route of its own blueprint, and nothing else. It is not a personal access token and acts as no one.

The base URL is the Caldera app's own origin, https://app.calderaapp.io. The routes answer on that origin only.

POST /api/agent/v1/report

The agent's whole current state. Every field but protocol is optional; what is missing, Caldera does not show. A null counts as missing, so a reporter may send an unset field either way; the one exception is settings.values, where null means "this setting is not set" and is kept. Times are Unix seconds, shares are numbers from 0 to 1.

{
  "protocol": 1,
  "agent": { "version": "1c1f3c5", "host": "build-1" },
  "state": { "level": "ok", "label": "Ready", "detail": "waiting for work" },
  "health": [{ "name": "Chat bot", "level": "ok", "detail": "connected" }],
  "active": [
    {
      "kind": "Run",
      "model": "Opus",
      "started": 1791030000,
      "steps": 37,
      "detail": "last tool: Bash",
      "subjects": [{ "title": "…", "url": "https://…" }],
      "log": "run-20261003-124101.log"
    }
  ],
  "gauges": [{ "name": "Week", "used": 0.52, "mark": 0.7, "detail": "limit today 70 %" }],
  "runs": [
    {
      "id": "run-20261003-124101",
      "started": 1791024061,
      "ended": 1791024725,
      "kind": "Run",
      "status": "ok",
      "cost_usd": 2.51,
      "output_tokens": 22000,
      "models": { "Opus": 2.51 },
      "subjects": [{ "title": "…", "url": "…", "share": 1.0 }],
      "log": "run-20261003-124101.log"
    }
  ],
  "panels": [
    {
      "title": "Questions for you",
      "type": "list",
      "items": [{ "title": "…", "url": "…", "meta": "…" }]
    }
  ],
  "settings": {
    "schema": [
      {
        "key": "max_7d",
        "label": "Weekly limit",
        "type": "percent",
        "min": 0.1,
        "max": 0.95,
        "help": "…",
        "group": "Quota"
      }
    ],
    "values": { "max_7d": 0.75 }
  },
  "controls": ["pause", "resume", "run_now"],
  "acks": [{ "id": "3f0c…", "ok": true, "message": "Weekly limit set to 80 %." }]
}
  • level: ok, info, warn or bad. An unknown level reads as info.
  • runs: the most recent runs. Caldera keeps them (the newest 2000 per agent), so older runs survive the agent sending only a few each time. The same id overwrites: report a run while it runs and again when it ends.
  • panels: what only this agent has, in one of four shapes:
    • list: items with title, optional url, meta, badge, level;
    • table: columns (headings) and rows (cells as text or {"text", "url"});
    • kv: items with label and value;
    • text: lines.
  • settings.schema[].type: percent, number, bool, datetime (Unix seconds), text, select (with options: [{"value", "label"}]).
  • controls: the buttons Caldera may show. Unknown entries are dropped.
  • acks: answers to commands from earlier reports.

Everything is text. Caldera draws every string as text, never as markup. Links are kept only if they start with http:// or https://; anything else is dropped and the title stays.

Sizes. A report may be at most 256 KB. Strings and lists longer than their caps are cut, not refused (for example a label at 80 characters, a detail at 300, a list of health items at 50). A report that is not valid JSON, not an object, or names another protocol is refused with 400. Unknown fields are ignored, so a newer reporter can talk to an older server.

The answer

{ "commands": [{ "id": "3f0c…", "type": "set", "payload": { "max_7d": 0.8 } }], "interval": 30 }
  • interval: the reporting interval Caldera asks for, in seconds. Clamp it to a range of your own.
  • commands, one of:
    • pause, resume, run_now, with an empty payload;
    • set: setting values, only keys from the schema the agent reported. Caldera checks type and bounds against that schema before it sends a command; the agent checks again and decides;
    • log: {"name": "<log>"}, one of the log names the agent reported. Send that log to POST /api/agent/v1/log; no ack is needed, the upload is the answer.

A command id is an opaque string. Echo it back; do not parse it.

A command is delivered at most once. If the answer to a report is lost, the command is lost with it, and Caldera fails it when no answer has come after ten minutes. That is deliberate: run_now must never run twice because a network dropped a reply. A person can send it again.

POST /api/agent/v1/log

{ "command": "3f0c…", "name": "run-20261003-124101.log", "text": "…" }

At most 512 KB of text: send the end of the log. Caldera keeps it for 30 days, and only admins of the workspace can read it.

Errors

Every error is a JSON envelope {"error": {"code", "message"}}.

Status When
400 The body is not a valid report or log upload.
401 No agent token, an unknown one, or one that was rotated or deleted.
403 The workspace is not activated for Caldera.
404 The route was called on an address that is not Caldera's own, or the log names an unknown command.
413 The body is larger than the route accepts.
429 More than 60 reports or 10 logs a minute from one agent.

None of these may stop the agent. Log them and try again at the next interval.

Changes to the protocol

Version 1 grows additively or not at all: a new optional field, a new panel type, a new command type that is reachable and reversible without Caldera. A change that would break a deployed reporter is version 2, on a new path, with version 1 kept until its reporters have moved.

Additions for blueprints

Every addition below is optional: a plain version 1 reporter that sends and reads none of it keeps working. The runtime caldera.pyz speaks all of it; an own reporter needs none of it.

In the report: applied

{ "applied": { "version": "1.1.0", "outcome": "ok", "message": "updated to 1.1.0" } }
  • version: the blueprint version the agent runs now, the one that works.
  • outcome of the agent's last update attempt: ok, rolled_back (the new version failed its health check, or got worse in the window, or a person ran caldera rollback: the agent is back on the old one) or failed (nothing changed: the bundle could not be fetched, was refused, or an update was interrupted and undone).
  • message: one line, at most 300 characters, text. Before any update it reads installed.

It is sent in every report, so the last outcome is never lost to one failed report. agent.version carries the same version.

In the answer: desired

{ "commands": [], "interval": 30, "desired": { "blueprint": "ops-agent", "version": "1.1.0" } }

The version Caldera wants the agent to run. The agent applies it when it is not already running it, blueprint is its own, and version is a semantic version (anything else is ignored, never used as a path). A version the agent rolled back from, or refused, is not applied again until desired names a different one: otherwise a local caldera rollback would be undone by the next report. A failed fetch (Caldera unreachable) is the exception: it is retried after ten minutes. To roll back from Caldera, name the previous version in desired.

Caldera sends desired for as long as an admin's wish stands and omits it otherwise.

GET /api/agent/v1/bundle/:version

The files of one version of the agent's own blueprint. Authorization: Bearer caldera_agt_…; another blueprint's version answers 404, as does an unknown one.

  • 200, Content-Type: application/zip, body: the bundle (below), and X-Caldera-Sha256: <hex>, the checksum of the body. The agent refuses a body that does not match it.
  • The bundle holds the blueprint's files and nothing else: no secrets, no agent.env. Those exist only in the install answer.
  • At most 20 MB (the runtime refuses anything larger).

POST /api/agent/v1/install/preview and POST /api/agent/v1/install

Two calls with no token: the one-time install code is the credential, valid once, for fifteen minutes, for one installation of one blueprint version. Both take

{ "code": "caldera_inst_…" }

Preview first. It reads what the code would install and creates and spends nothing, so the CLI can ask for the secrets, and notice a directory that is taken, before the code is used. Answer 200:

{
  "blueprint": "ops-agent",
  "version": "1.0.0",
  "description": "…",
  "agentName": "ops-agent-1",
  "runtime": "process",
  "secrets": [{ "name": "SERVICE_KEY", "purpose": "…", "optional": true }],
  "bindings": [{ "product": "<product id>", "role": "member", "scopes": ["content:read"] }]
}

No secret value, no token and no file name is in it. agentName becomes a directory name: lowercase letters, digits and hyphens.

Then redeem. Answer 200, once:

{
  "agent": { "name": "ops-agent-1" },
  "blueprint": "ops-agent",
  "version": "1.0.0",
  "env": {
    "CALDERA_TOKEN": "caldera_agt_…",
    "CALDERA_BINDING_<PRODUCT>_URL": "https://…",
    "CALDERA_BINDING_<PRODUCT>_WORKSPACE": "3f0c…",
    "CALDERA_BINDING_<PRODUCT>_TOKEN": "…"
  },
  "bundle": "<base64 of the zip>",
  "sha256": "<hex of the zip>"
}
  • agent.name is the preview's. The runtime refuses an install that names another.
  • env becomes .caldera/agent.env. CALDERA_TOKEN is the agent's token. Each binding of the manifest adds three variables, with <PRODUCT> the product id in capitals: CALDERA_BINDING_<PRODUCT>_URL (the product's origin), CALDERA_BINDING_<PRODUCT>_WORKSPACE (the workspace id the bot belongs to; the product's API addresses it by id) and CALDERA_BINDING_<PRODUCT>_TOKEN (the bot's token, valid for a year, stamped for that product). CALDERA_URL is written by the runtime from the address it was given. Names are [A-Z][A-Z0-9_]*; a value with a line break is refused.
  • The bundle is the version's archive, byte for byte, and sha256 is of those bytes. It is the same archive GET /bundle/:version serves later.
  • An unknown, expired and used code all answer the same 404, on both calls, so a code cannot be probed. Rate-limited per address (429): twenty calls a minute, both together.
  • Redeeming is one transaction: it claims the code, creates the agent, and creates (or reuses by name) one bot per binding with a token each. A refusal rolls all of it back and the code stays usable: 403 when a bound product is not activated for the workspace or the person who issued the code may no longer install agents, 409 when the workspace would hold more than ten bots, when a bot of that name exists with another role, or when the workspace has an agent of that name already. The message says which.
  • An unredeemed code leaves no agent behind, and since the preview comes first, neither does a setup that is aborted at a secrets prompt. What can still fail after the redeem is the machine's own work (unpacking, writing files); the runtime then says that the agent exists in Caldera without a machine, and a person deletes it there and issues a new code.

What the runtime reports about bindings

For each binding the runtime adds a health item named Binding <product> to its report, at most once in five minutes, from one request to the product with the bot's token: GET <url>/api/v1/workspaces/<workspace>. ok for 200, and for 403 (a token whose scopes do not cover that read still works); bad for 401 (revoked, expired or deleted token) and 404 (the bot is no longer a member); warn when the product cannot be reached. Caldera shows bad as a broken binding on the agent; only the product's own settings can mend it. These items never count in the agent's own level for the update rollback window.

GET /cli/caldera.pyz

The CLI and runtime itself, served by the app on Caldera's origin (no token: the archive is the same for everyone and holds no secret), with /cli/caldera.pyz.sha256 beside it (<hex> caldera.pyz) and /cli/caldera.pyz.sig, the release's OpenSSH signature (namespace caldera-runtime) of exactly those bytes, which a runtime checks before it replaces itself. The signature answers 404 when the build was not signed. The release version the archive was built for is the runtime.version the answer names. All three answer 404 on any other address than Caldera's own.

The bundle

A zip archive with caldera.agent.yaml at its root (the manifest). The runtime treats it as untrusted and refuses the whole bundle, leaving nothing on disk, if any entry:

  • has an absolute path, a .. segment, a backslash, a drive letter or a NUL;
  • is a symlink, a device or any other non-regular file;
  • appears twice, or lies under .caldera/ (the runtime's own directory);
  • the archive has more than 5000 entries, unpacks to more than 100 MB (counted while unpacking, not read from the headers), or one file to more than 50 MB;
  • is missing a file the manifest names (instructions, mcp, a compose file), or its manifest is invalid, or its version is not the one that was asked for.

The server applies the same rules at upload; the runtime does not rely on it.

What the runtime gives the agent

The runtime launches the agent's process (runtime.command, an argument list, never a shell) in current/<workdir> with the environment below. The agent needs none of it to run; it is how it cooperates without Caldera.

Variable Is
everything in .caldera/agent.env except CALDERA_TOKEN the binding tokens and URLs. The agent never receives the token that speaks for the runtime.
everything in .caldera/secrets.env the secrets the person typed at caldera init
CALDERA_SETTING_<KEY> each setting's current value (true/false for bool, empty if unset)
CALDERA_SETTINGS_FILE JSON of all settings, rewritten on every change
CALDERA_PAUSE_FILE exists while the agent is paused; the runtime stops the process, so checking it is optional
CALDERA_RUN_NOW_FILE created by run_now; the agent removes it when it acts
CALDERA_STATE_DIR, CALDERA_AGENT_DIR the state directory and the agent's directory
CALDERA_AGENT_STARTED Unix time (seconds, 3 decimals) at which this process was started, for a health check that must see the new version work

The agent reports by writing the JSON file the manifest names as status, in the shape of a report: state, health, active, gauges, runs, panels. Other keys are not forwarded; a file that is not an object costs one health line. The runtime adds agent, settings, controls, acks, applied and a Process health item, and replaces state with Paused or Not running when that is the truth.

What the runtime does with the commands of this page, so that the server can rely on it:

  • pause/resume: the same .caldera/paused file caldera pause and caldera resume write. run_now is refused while paused. A command the manifest's controls does not offer is refused with an ack.
  • set: every key must be a declared setting and every value inside its bounds, or nothing changes. null clears a setting only if the manifest gives it no default.
  • log: only names declared in the manifest's logs, only from a file that resolves inside the agent's directory and outside .caldera/; anything else is answered with the text There is no such log.

An update is: fetch, stage beside the running version, swap current (one atomic rename), restart, run health, keep or swap back. state paths live in <dir>/state and are linked into every version; a file a bundle ships at a state path is a seed, copied only if nothing is there. For 30 minutes after an update the agent rolls itself back if its own health level gets worse than it was when the update was kept. A process killed between any two steps is undone or finished at the next start. None of this needs Caldera except the fetch.

Additions for the runtime itself

Also optional and additive. They concern the program caldera.pyz, as applied concerns the blueprint version.

In the report: runtime

{
  "runtime": {
    "version": "0.32.0",
    "previous": "0.31.0",
    "pinned": "0.32.0",
    "applied": { "version": "0.32.0", "outcome": "ok", "message": "updated to 0.32.0" }
  }
}
  • version: the runtime version that runs now. The release version of the Caldera that built the file (dev for a runtime run from source). Required inside runtime; the field runtime itself is optional.
  • previous: the version kept beside it, which caldera rollback-runtime goes back to. Absent when there is none.
  • pinned: a version a person pinned on the machine (caldera pin-runtime). While it is set, the runtime does not update to any other version, whatever Caldera wishes. Absent when not pinned.
  • applied: how the last self-update attempt ended, with the outcomes of the blueprint applied (ok, rolled_back, failed), version being the one that works now and message one line (at most 300 characters). Absent before the first attempt; sent in every report afterwards, so one lost report loses nothing.

A runtime sends it in every report. An old runtime sends none.

In the answer: runtime and runtime_desired

{
  "commands": [],
  "interval": 30,
  "runtime": { "version": "0.32.0", "sha256": "<hex of /cli/caldera.pyz>" },
  "runtime_desired": "0.32.0"
}
  • runtime: the runtime this Caldera serves at /cli/caldera.pyz: its release version and the SHA-256 of that file (the same value as /cli/caldera.pyz.sha256). Information. The agent page shows that a newer runtime is available when runtime.version differs from the agent's own runtime.version.
  • runtime_desired: a wish, present only while an admin's "update runtime" press stands and omitted otherwise. The runtime never updates itself unasked. It names the version the agent should run, which is the version of runtime. A plain runtime never sees it.

Both fields are only in the answer to a report that carries runtime: a reporter that sends no runtime gets the answer it always got. Both are absent when this Caldera serves no runtime build.

How Caldera keeps the wish. The press stores the served version as the wish, together with a mark of the agent's last runtime.applied (outcome and message, not the version) at that moment. With each report Caldera decides:

The report shows The wish
The served version is no longer the wished one (a newer release replaced it) dropped; runtime_desired never names an older version than the one served
applied unchanged since the press (a runtime still on trial reports the version it runs, with the old outcome) stands
applied.outcome: ok and runtime.version equal to the wish over; the hold is cleared
applied.outcome: rolled_back or failed, different from the one at the press over; the wished version is held

A held version is refused at the next press (runtime_version_held) until Caldera serves another one. The press is also refused for a runtime that reports no runtime (runtime_too_old), for a Caldera with no build (runtime_not_served), for a runtime already at the served version (runtime_up_to_date) and for one pinned to another version (runtime_pinned). A failure that is only the machine's or the network's (no ssh-keygen, a failed download) is held too: the runtime retries those by itself while the wish stands, but Caldera cannot tell them from a refused file, so the person presses again after the release changes or the machine is mended.

What the runtime does with the wish

  1. Is it wanted? The wish is applied when all hold: runtime_desired is a version string ([0-9A-Za-z][0-9A-Za-z._-]{0,63}, never used as a path), it is not the running version, it is not the version the runtime rolled back from or refused (a hold, lifted when runtime_desired names a different one or the runtime is replaced), and it is not ruled out by a local pin.
  2. Fetch. GET /cli/caldera.pyz and GET /cli/caldera.pyz.sig on Caldera's origin (no token: neither holds a secret, and the runtime never sends its token there). The SHA-256 must equal the one in runtime.sha256 of the answer when that names the same version, and the X-Caldera-Sha256 header when present. The file's own version must equal the wish.
  3. Verify the signature, always, with ssh-keygen -Y verify -n caldera-runtime -I caldera-release against the public key built into the runtime. A bad or missing signature, or no ssh-keygen: nothing changes, the outcome is failed and the message says which.
  4. Swap and restart. The running file is kept as the previous one, the new file replaces it with one rename, and the runtime restarts itself in place (the same process id; the agent's own processes are not touched).
  5. Trial. The first successful report of the new runtime within five minutes makes it good (ok). A runtime that does not start, crashes repeatedly, or does not report successfully in the window goes back to the previous file by itself (rolled_back), and the version is held.

caldera rollback-runtime and caldera pin-runtime <version|off> work with no connection at all. See the runtime.

In the report: account

{ "account": { "org": "Example Org", "plan": "max", "email": "someone@example.com" } }

The Claude account the agent's machine is signed in to. All three fields are text and optional. See The Claude account hint.