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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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,warnorbad. An unknown level reads asinfo.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 sameidoverwrites: report a run while it runs and again when it ends.panels: what only this agent has, in one of four shapes:list:itemswithtitle, optionalurl,meta,badge,level;table:columns(headings) androws(cells as text or{"text", "url"});kv:itemswithlabelandvalue;text:lines.
settings.schema[].type:percent,number,bool,datetime(Unix seconds),text,select(withoptions:[{"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 emptypayload;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 toPOST /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.outcomeof 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 rancaldera rollback: the agent is back on the old one) orfailed(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 readsinstalled.
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), andX-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.nameis the preview's. The runtime refuses an install that names another.envbecomes.caldera/agent.env.CALDERA_TOKENis 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) andCALDERA_BINDING_<PRODUCT>_TOKEN(the bot's token, valid for a year, stamped for that product).CALDERA_URLis 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
sha256is of those bytes. It is the same archiveGET /bundle/:versionserves 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/pausedfilecaldera pauseandcaldera resumewrite.run_nowis refused while paused. A command the manifest'scontrolsdoes not offer is refused with an ack.set: every key must be a declared setting and every value inside its bounds, or nothing changes.nullclears a setting only if the manifest gives it no default.log: only names declared in the manifest'slogs, only from a file that resolves inside the agent's directory and outside.caldera/; anything else is answered with the textThere 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 (devfor a runtime run from source). Required insideruntime; the fieldruntimeitself is optional.previous: the version kept beside it, whichcaldera rollback-runtimegoes 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 blueprintapplied(ok,rolled_back,failed),versionbeing the one that works now andmessageone 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 whenruntime.versiondiffers from the agent's ownruntime.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 ofruntime. 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
- Is it wanted? The wish is applied when all hold:
runtime_desiredis 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 whenruntime_desirednames a different one or the runtime is replaced), and it is not ruled out by a local pin. - Fetch.
GET /cli/caldera.pyzandGET /cli/caldera.pyz.sigon Caldera's origin (no token: neither holds a secret, and the runtime never sends its token there). The SHA-256 must equal the one inruntime.sha256of the answer when that names the same version, and theX-Caldera-Sha256header when present. The file's own version must equal the wish. - Verify the signature, always, with
ssh-keygen -Y verify -n caldera-runtime -I caldera-releaseagainst the public key built into the runtime. A bad or missing signature, or nossh-keygen: nothing changes, the outcome isfailedand the message says which. - 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).
- 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.