Adapter mode and its contract
Everything an agent author needs to build an adapter and nothing else: what an adapter is, how the runtime calls it, the verbs with their input, output and exit codes, the environment, working directory, timeouts and size caps it runs under, the bounds rule, and a complete minimal example. This page is the interface. Which files the adapter reads and writes is the agent's own business and is not part of it.
The machine half is published as JSON Schemas:
adapter-status.schema.json,
adapter-settings-get.schema.json,
adapter-log-list.schema.json and
adapter-answer.schema.json. Where this
page and a schema disagree, that is a bug in one of them.
What an adapter is
An agent that keeps its own processes (a timer, a bot, a watchdog, a pause file
shared with a chat command) cannot be run by the runtime: a runtime that stopped a
process would add a second pause nobody else knows about. So a manifest says
runtime.type: adapter, and the runtime then
- starts nothing and stops nothing, and reads no file of the agent;
- reports to Caldera in the agent protocol, with the body it gets
from
adapter status; - passes Caldera's commands on by calling the adapter, one command the agent provides, with a verb.
The adapter is the agent's code. It is a program (any language) that takes a verb as its arguments, reads and writes the agent's own files, prints JSON, and exits. Nothing about it is long-running: the runtime starts it for each call.
# caldera.agent.yaml
name: task-agent
version: 1.0.0
description: The task agent of the ops team.
runtime:
type: adapter
command: [.venv/bin/python, caldera/adapter.py] # argv: never a shell string
workdir: /home/agent/task-agent # the agent's own checkout
timeout_seconds: 25 # optional, 1 to 120
settings:
schema:
- { key: max_7d, label: Weekly limit, type: percent, min: 0.1, max: 0.95 }
defaults: { max_7d: 0.75 }
controls: [pause, resume, run_now]
account: false # optional, see the account hint
caldera init for such a blueprint installs the manifest and no agent process:
the bundle carries only the manifest (and the adapter, if the agent wants it
shipped; then workdir is a path inside the bundle). The systemd unit runs only
the runtime. An update from Caldera changes the manifest and its (narrowing)
bounds, not the agent's code.
A manifest with runtime.type: adapter may not name status, logs, state,
secrets, bindings, instructions or mcp: those are files and credentials the
runtime would read or hand over, and in this mode it does neither. It may name
settings, controls, health, account and runtime.timeout_seconds.
Blueprint updates and the 30-minute window work as for any agent: the manifest
changes, and the window reads the level of adapter status. The health check runs
in the adapter's working directory with the adapter's environment.
How it is called
<command...> <verb> [arguments]
command is the manifest's argv, the verb and its arguments are appended, never
passed through a shell. Every call is a new process.
| User | The runtime's own user. There is no privilege change. |
| Working directory | runtime.workdir: an absolute path (the agent's checkout, outside the bundle) or a relative path inside the bundle. Default: the bundle's version directory. A directory that does not exist is a failure of the call. |
| Program | command[0] with a slash is resolved against the working directory; without one it is looked up in PATH. |
| stdin | Closed (/dev/null). |
| stdout | The answer: JSON as each verb says below. Read up to the verb's cap. |
| stderr | Not an answer. The first 2 KB are kept, and a failing call's first line may appear in the failure message. Put diagnostics here, never secrets. |
| Session | Its own process group. A call that times out is killed with everything it started. |
| Environment | Minimal, below. |
The environment
The adapter gets only this, and no CALDERA_TOKEN, nothing from the runtime's
agent.env or secrets.env, and nothing else of the runtime's environment:
| Variable | Is |
|---|---|
PATH, HOME, USER, LOGNAME, LANG, LC_ALL, LC_CTYPE, LC_MESSAGES, TZ, TMPDIR |
copied from the runtime, when set |
XDG_RUNTIME_DIR, XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, DBUS_SESSION_BUS_ADDRESS |
copied, when set (an adapter may talk to systemctl --user) |
CALDERA_ADAPTER |
always 1: "I was called by the runtime" |
CALDERA_ADAPTER_VERB |
the verb: status, pause, resume, run-now, settings, log |
CALDERA_AGENT_NAME |
the agent's name in Caldera |
The adapter reads what it needs (its own credentials, config) from its own files and environment sources. The token that speaks for the runtime stays out of its reach by the environment; the file permissions of a single user cannot stop a program from reading a file, which is why the adapter is the agent's own code.
Exit codes
| Code | Means | The runtime |
|---|---|---|
0 |
Done. Stdout is the verb's answer. | Uses it. |
1 |
Refused. The agent said no, in a sentence (stdout {"message": ...} or the first line of text, else stderr). A refusal is a normal answer. |
Shows the message to the person as the answer to their command. Nothing changed. |
2 |
Usage: the adapter does not know this verb or its arguments. | A failure: "the adapter does not know ...". |
| any other, or killed by a signal | Failure. | A failure with the code, and the first line of stderr. |
A timeout and an output over the cap are failures too (the process is
killed). On status a failure costs that one report its body: Caldera is told
"Adapter not answering" (state bad, a health item Adapter with the reason), and
the next report works as soon as the adapter does; acks of earlier commands still
go out. On a command verb, a failure is a not-ok answer to the person's command
(The agent's adapter failed: ...). Nothing else in the agent changes, and the
runtime never holds the agent's work: if the adapter is broken, the agent keeps
working on its own.
Timeouts and caps
| Verb | Timeout | Stdout cap |
|---|---|---|
status |
20 s | 200 KB |
settings get |
20 s | 100 KB |
log --list |
20 s | 64 KB |
log <name> |
20 s | 4 MB read; the last 512 KB are used |
pause, resume, run-now, settings set |
30 s | 64 KB |
runtime.timeout_seconds in the manifest replaces every timeout above (1 to 120).
Past a cap the process is killed and the call fails. Strings and lists inside an
answer that are longer than the protocol's caps are cut by Caldera, not refused.
The verbs
status
adapter status
Prints one JSON object: the body of a report, in the shape a process agent writes to its status file. All keys are optional. The runtime forwards only these, key by key:
| Key | Is |
|---|---|
state |
`{level: ok |
health |
[{name, level, detail?}] |
active |
what is running now (kind, model, started, steps, detail, subjects, log) |
gauges |
[{name, used (0 to 1), mark?, detail?}] |
runs |
the most recent runs (id, started, ended, kind, status, cost_usd, output_tokens, models, subjects, log); the same id overwrites |
panels |
list, table, kv or text panels |
agent |
{version}: the agent's own version (a commit), which replaces the blueprint version in the report |
Any other key is dropped. settings and controls do not come from here: the
schema and the buttons are the manifest's, the values come from settings get. The
log names in active[].log and runs[].log are the names adapter log <name> will
be asked for. See the protocol for what every field means and its
caps.
The adapter may answer from a cache it keeps itself (for example the board
state and its age, refreshed in the background), so a status call never has to
reach another service. If it does, it should say how old the data is in a detail.
A call that runs longer than the timeout is killed: the cache is the way to stay
inside it.
Schema: adapter-status.schema.json.
pause
adapter pause
Sets the agent's own manual pause: the one its other channels set and clear (a
chat command, a watchdog), so Caldera adds no second pause. Exit 0 with an
optional {"message": "Paused."}.
resume
adapter resume
Clears the manual pause only. Other holds stay (a person working in a session, a quota stop), and the answer names them:
{ "message": "Pause lifted. Alex is working in a session, so no run starts.", "held": ["Alex is working in a session"] }
message is the sentence a person reads. held is the list of holds still
standing; the runtime appends any hold the message does not already contain
("Held: ..."). Exit 0.
run-now
adapter run-now
Asks for a run: a request, not an order. The agent's own holds (pause, quota, a session) still apply, and the answer says when the run is held:
{ "message": "Requested, but held.", "held": ["paused"] }
Exit 0 when the request was recorded (even if held); exit 1 with a message when
it could not be, for example because there is nothing to run.
settings get
adapter settings get
Prints the current values of the agent's own settings source, and optionally the agent's own hard bounds:
{
"values": { "max_7d": 0.75, "note": null },
"bounds": { "max_7d": { "min": 0.1, "max": 0.95 } }
}
values: one key per setting the manifest declares;nullfor unset. Keys the manifest does not declare are ignored.bounds(optional): per keyminand/ormax. Caldera offers the narrower of the manifest's bounds and these (see below).
Schema: adapter-settings-get.schema.json.
settings set
adapter settings set '{"max_7d": 0.5, "note": "hello"}'
The one argument is a JSON object of key to new value (null clears), at most
8 KB.
- All or nothing. If any value is refused, nothing is written. Write the settings source atomically (a temporary file, then a rename), so a reader sees the old or the new file.
- Exit
0when applied, with an optional message (Saved; the next run uses it.). A change takes effect with the agent's next run. - Exit
1when refused, with the reason in a message. The runtime shows it asNothing changed: <message>.
log --list and log <name>
adapter log --list
adapter log run-20261003-124101.log
--list prints the names the agent hands out:
{ "logs": [{ "name": "run-20261003-124101.log" }, { "name": "chat-ops.log" }] }
At most 50 names of at most 190 characters are used. log <name> prints the
tail of one log as plain text on stdout (the runtime keeps the last 512 KB and
sends them to Caldera). The adapter decides which names exist: they may follow
patterns (run-YYYYMMDD-HHMMSS.log); the manifest names no list. For an unknown
name exit 1 (the runtime answers "There is no such log."). The runtime refuses,
without calling, names that are empty, longer than 190 characters, contain NUL or
start with -. Validate the name yourself as well: it comes from Caldera, and
a path in it is yours to refuse (../../etc/passwd).
Schema of the answers of pause, resume, run-now and settings set:
adapter-answer.schema.json; of
log --list: adapter-log-list.schema.json.
Bounds only narrow
The adapter's own hard bounds have the last word. A manifest may narrow them and never widen them: no manifest update can, for example, open a weekly cap to 100 % when the agent's own limit is 95 %. How that works:
- The manifest's
settings.schemahas bounds (min,max,step, options), and the runtime checks everysetagainst them first. A value outside them is refused by the runtime, and the adapter is not called. - If
settings getreportsbounds, the runtime narrows the manifest'sminandmaxwith them (the largermin, the smallermax). That narrowed schema is what the report offers and what the check uses, so Caldera shows no value the adapter would refuse. - The runtime passes on what survived, and the adapter checks again against its
own rules and may still refuse (exit
1). Its answer is the answer.
Callers cannot weaken the adapter, only ask it. Write the adapter so that it would be safe if the manifest allowed everything.
Pause and resume without Caldera
caldera pause and caldera resume on the machine call the same adapter (pause,
resume) and need no connection, and the agent's own channel (for example a chat
command) works as before. caldera log [name] lists or prints logs the same way.
Everything Caldera can do is reachable and reversible without it.
A complete minimal adapter
#!/usr/bin/env python3
"""Adapter for an agent whose state is a few files in its working directory."""
import json, os, sys
def say(message, held=None, code=0):
print(json.dumps({"message": message, **({"held": held} if held else {})}))
sys.exit(code)
def main(argv):
verb = argv[0] if argv else ""
if verb == "status":
paused = os.path.exists("paused")
print(json.dumps({
"state": {"level": "warn", "label": "Paused"} if paused else {"level": "ok", "label": "Ready"},
"gauges": [{"name": "Week", "used": 0.5}],
"agent": {"version": "abc1234"},
}))
elif verb == "pause":
open("paused", "w").close()
say("Paused.")
elif verb == "resume":
if os.path.exists("paused"):
os.remove("paused")
say("Pause lifted.")
elif verb == "run-now":
open("run-now", "w").close()
say("Requested.")
elif verb == "settings" and argv[1:2] == ["get"]:
print(json.dumps({"values": {"max_7d": 0.75}, "bounds": {"max_7d": {"min": 0.1, "max": 0.95}}}))
elif verb == "settings" and argv[1:2] == ["set"]:
wanted = json.loads(argv[2])
for key, value in wanted.items():
if key != "max_7d" or not 0.1 <= value <= 0.95:
say(f"{key}: not allowed.", code=1) # all or nothing: nothing was written
with open("settings.json.tmp", "w") as f:
json.dump(wanted, f)
os.replace("settings.json.tmp", "settings.json") # atomic
say("Saved.")
elif verb == "log" and argv[1:2] == ["--list"]:
print(json.dumps({"logs": []}))
else:
sys.exit(2) # a verb it does not know
main(sys.argv[1:])
Checklist for an adapter author
- Prints exactly one JSON object on stdout for
status,settings get,log --list, and nothing else on stdout. Diagnostics go to stderr. - Answers
statusfrom its own cache when the real source is slow; never takes the full 20 s on purpose. pauseandresumetouch only the manual pause.resumenames the holds that stay.run-nowrecords a request and says if it is held.settings setis atomic and all or nothing, checks against the agent's own bounds, and exits1with a reason instead of clamping silently.- Validates log names; prints only the tail.
- Exits
2for a verb it does not know,1to refuse,0when done, and never exits0after failing. - Works the same with Caldera unreachable: the runtime calls it locally for
caldera pause.