Caldera docs
calderaapp.io

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; null for unset. Keys the manifest does not declare are ignored.
  • bounds (optional): per key min and/or max. 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 0 when applied, with an optional message (Saved; the next run uses it.). A change takes effect with the agent's next run.
  • Exit 1 when refused, with the reason in a message. The runtime shows it as Nothing 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:

  1. The manifest's settings.schema has bounds (min, max, step, options), and the runtime checks every set against them first. A value outside them is refused by the runtime, and the adapter is not called.
  2. If settings get reports bounds, the runtime narrows the manifest's min and max with them (the larger min, the smaller max). That narrowed schema is what the report offers and what the check uses, so Caldera shows no value the adapter would refuse.
  3. 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 status from its own cache when the real source is slow; never takes the full 20 s on purpose.
  • pause and resume touch only the manual pause. resume names the holds that stay.
  • run-now records a request and says if it is held.
  • settings set is atomic and all or nothing, checks against the agent's own bounds, and exits 1 with a reason instead of clamping silently.
  • Validates log names; prints only the tail.
  • Exits 2 for a verb it does not know, 1 to refuse, 0 when done, and never exits 0 after failing.
  • Works the same with Caldera unreachable: the runtime calls it locally for caldera pause.