Skip to content

Control Surface

A capsule whose manifest declares control: can be changed while it runs by whoever holds its session's control token. It serves a control surface under /control on the same listener as its A2A door: plain HTTP with JSON bodies, separate from the JSON-RPC methods on POST /, and absent from the agent card.

What a controller changes Declared by Takes effect Command
A setting control.settings From the agent loop's next inference call mur control set
A secret control.secrets On the credential gateway's next keyed request mur control secret, mur control forget

A capsule with no control: block — or with control: {}, or all three lists empty, or only control.agent_settings — has no control surface. It mints no token, and every request under /control is answered 404.

The agent is never a controller. No manifest key hands the token to a tool, hook, driver or shell command. control.agent_settings lets the agent change a setting about itself through the runtime-provided switch-driver tool, which the runtime answers in-process without the token or the surface.

Where the session's filesystem restriction is advisory, a shell command or native tool can read any file you can, the token file included: on such a host, do not grant capabilities.shell to a capsule whose controls the agent must not reach. mur run --explain-scope --json reports the restriction as filesystem_boundary.restriction.


Controllable settings

Setting Value Valid under Read by
inference.driver The name of a declared driver choice: primary or an inference.alternates entry inference.transport: http, with at least one alternate Every agent-loop inference call: the driver it is dispatched to, the model, the gateway and credential, the context-occupancy count and the trace. Starts on primary
inference.max_tokens Integer, 1 to 4294967295 inference.transport: http Every agent-loop inference call: the max_tokens sent to the driver, the spend admission, the context-occupancy count and the truncation warning

Each inference call reads each setting once, so one call never mixes two values. Compaction calls and a hook's run-inference read neither: they stay on the primary driver and the manifest's cap.

A switch to a driver choice is accepted only when its credential can produce a value now:

Choice's credential Switch
Resolved at launch from the config, the environment or a literal Accepted
A control.secrets name with a value injected Accepted
A control.secrets name with nothing injected Refused 409 credential_unresolvable
Found nowhere at launch (W-RUN-003) Refused 409 credential_unresolvable

A secret that keys the choice in use cannot be forgotten: DELETE is refused 409 in_use and the value is kept. Switch to another choice first.

Controllable secrets

A name in control.secrets backs an artifact's gateway.api_key: ${NAME}. The capsule launches without a value for it.

State A keyed request from that artifact
No value injected, or the value forgotten Refused inside the runtime; nothing is sent upstream. The tool sees an error naming the credential and saying it has not been injected
A value injected Sent with the value, rendered as the artifact's upstream_auth: says

The gateway reads the value on every keyed request, so a replacement reaches the next one. The global config's credentials: map and the environment are never consulted for a name in control.secrets.

The value is held in memory only. It is never written to disk, the trace, the conversation record, stderr or an error, and it is never placed in the agent's context or a tool's environment.


Endpoints

Method Path Body Answers 200 with
GET /control — {"session_id", "settings": [{"name", "value"}], "secrets": [{"name", "set"}]}. The inference.driver entry also carries "choices": [{"name", "driver", "model", "available"}], where available is whether a switch to it would be accepted now
PUT /control/settings/<name> content-type: application/json, {"value": <value>} {"name", "previous", "value", "applies_from": "next_inference_call"}. A driver choice's previous and value are choice names
PUT /control/secrets/<name> The raw value, exactly as sent: no JSON, no trimming {"name", "set": true, "replaced": <bool>}
DELETE /control/secrets/<name> — {"name", "set": false}

GET /control never carries a secret value. Every request carries Authorization: Bearer <token>.

Refusals

Every refusal has the body {"error": "<one sentence>"}. A secret's value never appears in one.

Status reason in the trace When
404 — The capsule declares no control: block. Nothing is recorded
401 unauthenticated Authorization: Bearer is missing or its token does not verify for this session. Carries WWW-Authenticate: Bearer realm="murmur-control"
404 unknown_path No control resource at the path
404 undeclared_setting The setting is not in control.settings, including one only control.agent_settings lists
404 undeclared_secret The secret is not in control.secrets
405 method_not_allowed A method the resource does not answer. Carries allow
403 not_loopback A secret PUT or DELETE from a peer that is not loopback
413 body_too_large content-length over 1024 bytes for a setting, or 8192 bytes for a secret
422 missing_body A PUT with no body, or content-length: 0
415 unsupported_media_type A setting PUT without content-type: application/json
422 invalid_value An inference.max_tokens value outside its range or not an integer, or a secret that is empty or holds any byte other than visible ASCII and space
422 no_input_budget An inference.max_tokens value at or above the context window of any declared driver choice, which would leave that choice's calls no input budget. The message names the first such choice in declaration order and its window: inference.max_tokens of 2000 leaves driver choice 'cheap' no input budget: its context window is 2000
422 undeclared_driver An inference.driver value that is not a string naming a declared choice. The message lists the declared names
409 credential_unresolvable An inference.driver choice whose credential cannot produce a value now. The message names the credential
409 in_use A secret DELETE while the secret keys the driver choice in use
400 truncated_body The connection closed before content-length bytes arrived

Order of checks

The checks run in this order, and a request stops at the first that refuses it:

  1. The capsule declares control:.
  2. The token verifies.
  3. The path, the name and the method.
  4. For a secret, the peer is loopback: 127.0.0.0/8, ::1, or an IPv4-mapped ::ffff:127.0.0.0/104.
  5. The declared content-length.
  6. The content type and the value.

The control token

Property Value
File ~/.murmur/running/<session_id>.control, mode 0600, in the 0700 running directory
Written At launch, before the session's running record, and only when control: is declared
Removed With the running record: when the session ends, on mur stop, and when a reader prunes a dead session's record
Shape ctl1.<base64url payload>.<base64url MAC>
Valid for This session only. The key that verifies it is generated in memory at launch and never written, so a token from a stopped session is refused with 401 — including by a new session of the same capsule on the same port

mur run never prints the token. The trace carries only its token_id, the first 16 hex characters of its SHA-256.

A capsule that declares control: and cannot write its token file refuses to launch with E-RUN-041.

Reaching a capsule on another host

mur control reads the token file on the host it runs on. A controller on another host gets the token from whoever launched the capsule, who reads the file on the capsule's host and hands it over, then sends requests to the capsule's address with Authorization: Bearer <token>.

Request Over a non-loopback address
A setting PUT Accepted. The door has no TLS, so the token crosses the network in clear
A secret PUT or DELETE Refused 403. A secret is accepted only over loopback, because the door has no TLS

A capsule is reachable from other hosts only when mur run --bind names a non-loopback address.


What is recorded

Event Written when
control_change A change is accepted. principal is controller, with the token's token_id, or agent, with none
control_refused A request to a declared surface, or an agent's switch-driver call, is refused. principal says which
control_applied The first agent-loop inference call to use a changed setting value, before the call is sent
session_start.control At launch: the declared setting, secret and agent-setting names
session_start.inference_choices At launch, when alternates are declared: every driver choice and whether its credential resolved
gateway_credential with source: "injected" An injected credential's keyed request found no value: once per period with no value

Field lists are in Observability schemas. mur trace show prints one line per control_change under Control, with the turn a setting applied from and who made the change, and, when alternates are declared, the choice that served each turn under Driver choices. mur control show lists every driver choice with its model, its driver and whether it is available.

Nothing is added to the conversation record, the A2A stream or mur watch: a control change is not a message to the model.

What is not persisted

Nothing a controller or the agent sets survives the process. A restart or mur run --resume starts every setting from the manifest — inference.driver on primary — holds no secret, and mints a new token.