Skip to content

Operator notes

Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.

An operator note is a short instruction from an identified human to a running agent. You type it in the console, the CLI or the API; the agent receives it at its next turn boundary; the note is recorded as a human decision, with who sent it, when it landed and on which turn. An agent can author one too, through an opt-in tool, and the record says so; see A note from an agent.

It exists because the alternative in an unattended run is to kill the agent and start again. A note is the cheaper correction: "the staging cluster is the one in eu-west-1", "stop refactoring the tests, ship the fix", "the customer changed the deadline". It is a steer, not a policy override. A note cannot approve a tool call, lift a kill switch, or grant anything the sender does not already have.

What it costs

Nothing when there is no note. The agent never polls, never decides to check an inbox, never spends a token asking. Delivery is a piece of work Preloop is doing anyway: the gateway is already assembling the outbound request body, and the permission hook is already making a round trip for the tool call. A session with no pending note reads one indexed row and appends nothing.

The turn that carries a note is a cache miss. Providers serve cached input only for a prefix that is byte-identical to a previous request, and a note is appended after the last cached block, so the turn that delivers it pays uncached input pricing for the segment it lands in and writes a new cache entry for the turns after it. On Anthropic, where the note becomes one more text block on the trailing user message, that means the final cache segment; on OpenAI, where caching is prefix-automatic, it means the tail of the prompt. One turn, once per note batch. It is the same cost as the human having typed the sentence themselves, which is exactly what happened.

Sending one

Console: the Operator notes card on the agent page and on a running flow execution. Enter sends, Shift+Enter is a newline. Each note shows its state, and an undelivered note can be withdrawn from the same card.

CLI, which is where an operator running an agent already is:

preloop notes send --agent 0b0d... "Deploy to eu-west-1, not us-east-1."
preloop notes send --session 5a3e... "Stop refactoring the tests, ship the fix."
preloop notes send --execution 9f2b... "The deadline moved to Friday."

Name exactly one of --agent, --session or --execution; naming none or naming two is refused before any request is made. The body is the argument, and with no argument it is read from standard input, so a multi line note can be piped or written in a heredoc:

cat <<'NOTE' | preloop notes send --agent 0b0d...
Two things:
  1. the cluster is the one in eu-west-1
  2. do not touch the tests
NOTE

--expires-in 2h overrides the 24 hour default. --json emits the note id and the target and nothing else, for scripts. A refusal prints the server's reason, including an unresolvable target and the rate limit, and exits non-zero. The CLI sends notes and does not read them: listing a note's delivery state and cancelling one stay in the console and the API.

Finding the session to steer

--session needs a session id. preloop sessions list shows them, most recently active first, with the short id, the agent, when it started and last did something, whether it is live, idle or ended, its tool and model call counts, how many approvals are waiting on a human, and a title:

preloop sessions list --active
ID        AGENT   STARTED  LAST ACTIVITY  STATE  TOOLS  MODEL  PENDING  TITLE
aaaaaaaa  Worker  9m ago   30s ago        live   7      12     1        claude_code alpha 2026-10-02 11:50:07Z
bbbbbbbb  Worker  9m ago   5m ago         idle   2      3      0        claude_code beta 2026-10-02 11:50:07Z
Steer: preloop notes send --session <id>. Watch: preloop sessions attach <id>.

A session the server has not titled yet is labelled with its agent kind, the working directory its hook last reported and its start time, so two workers started in the same second are still two different rows. --wide prints the full id that notes send --session takes, and -o id prints only full ids, one per line, for a pipe:

preloop sessions list --agent "Release worker" --active -o id | head -1 \
  | xargs -I{} preloop notes send --session {} "Ship the fix, leave the tests."

Narrow the list with --agent <id|name>, --kind claude-code, --since 2h, --parent <session-id> (the sessions a conductor spawned) and --execution <id>. The server applies every filter across the account. See the CLI reference. To watch the session and steer it from the same terminal, attach to it: Attaching to a session from the terminal.

API:

curl -X POST https://your-preloop/api/v1/operator-notes \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"agent_id": "0b0d...", "text": "Deploy to eu-west-1, not us-east-1."}'

Name exactly one target: agent_id, runtime_session_id or execution_id. A note addressed to an agent with no live session waits for the next session that agent opens, which is how you brief a run before it starts. expires_in_seconds overrides the 24 hour default; a note nobody delivered in a day is stale advice, and expiring visibly beats rotting silently.

  • GET /api/v1/operator-notes?agent_id=... (or runtime_session_id, execution_id) lists notes newest first with their delivery state.
  • POST /api/v1/operator-notes/{note_id}/cancel withdraws one that has not been delivered. Cancelling is a state, never a delete. A delivered note cannot be unsent, so cancelling one returns it unchanged and you can see why.

Mobile is out of scope for now. The iOS and Android clients already hold an account session, so they need no new backend: the three endpoints above are the whole surface.

A note from an agent

An agent can leave a note for another agent through the send_note builtin tool, so a hand off between two runs stops going through a person or a file nobody sweeps. It is off by default and has to be enabled per agent or per flow, like any other builtin: an agent that was never given the tool does not see it in its tool list and is refused if it calls it anyway.

{"text": "The migration is applied; run the backfill.", "agent_id": "0b0d..."}

Same one-target rule, same store, same delivery rail, same 4096 character and 20-per-hour ceilings, and the same account boundary: the target is resolved in the calling agent's account, so an id from another account is simply not found. Naming no target or two is a structured refusal the model can correct on its next turn, not an exception, and no note row is written.

What differs is only the author. The row records the calling managed agent rather than a user, the envelope carries "authMethod": "agent" and a display name suffixed (agent), and the audit row for the send names the agent as the actor. The delivered block's framing sentence follows the authors inside it: an all-agent delivery says the named agent does not hold the permission to stop this run, and a mixed human-plus-agent delivery names both authorities instead of wrapping the sibling's text in the human-stop sentence. The per-note from and auth attributes still name the author either way. The note still grants nothing: every action taken because of it goes through the firewall, the gateway and the approval policy as before.

An agent cannot note whatever it likes inside the account. What it can reach is the runs it started.

Steering the runs you started

A conductor agent that starts workers from a shell (claude -p "..." &) can steer them with send_note under the default scope, with no grant and no lookup through the API. Three pieces make that work once the Preloop hooks are installed (preloop agents onboard):

  1. Lineage. The SessionStart hook registers each run and exports its runtime session id to the commands the run starts, as PRELOOP_PARENT_SESSION_ID. A child whose own SessionStart hook sees that variable records the spawner as its parent_session_id, so the child is a descendant of the conductor exactly like a child flow execution. The parent must be in the same account, and the field is write-once.
  2. The id at spawn. SessionStart writes ~/.preloop/sessions/<external_session_id>.json with the runtime_session_id, started_at, agent_kind and cwd, and in non-interactive mode (claude -p) prints one line to stderr: preloop runtime_session_id=<id>.
  3. Discovery. The list_sessions builtin (off by default, enable it like send_note) returns the caller's live children with no arguments: id, start time, agent kind, cwd, parent, title and whether it is active now. Listing another session's children, or the whole account (parent_session_id: "any"), needs the same operator grant as search_sessions scope account and is refused by name without it.

The spawn recipe, from inside the conductor's Bash tool:

SID=$(uuidgen)
nohup claude -p --session-id "$SID" "Fix the flaky test in api/" \
  >"/tmp/worker-$SID.log" 2>&1 &
sleep 5
jq -r .runtime_session_id ~/.preloop/sessions/"$SID".json

Then any of these reaches that worker at its next turn boundary, recorded with the conductor as author:

{"text": "Skip the docs, only the test.", "runtime_session_id": "<id from the file>"}
{"text": "Skip the docs, only the test.", "external_session_id": "<SID>"}
{"text": "Wrap up and push.", "children": "all"}

external_session_id is the harness's own session id (the Claude Code session_id, which is also its transcript file name). It resolves inside your account and is refused as target_ambiguous if more than one run carries it. children takes latest (the newest live run your session started) or all (one note each, at most 20, each checked and rate limited on its own). Exactly one target field is still the rule.

The hook also sends the first prompt once, so the server sets a redacted title of at most 120 characters and a principal name of agent kind plus working directory. Two workers started in the same second show distinct titles in the console, preloop sessions list and GET /api/v1/runtime-sessions.

send_note knows which session called it from the credential, or, for a durable enrolled-agent key, from the harness session header on the MCP request matched against that key's own runs. A call that cannot be tied to a session is refused with note_scope_no_lineage; name the target by runtime_session_id from a session that can be, or use preloop notes send --session as the operator.

Which agent may note which target

A note from an agent is text a model will read on its next turn, so the blast radius of this channel is the other agent's behaviour, not just its transcript. The default is therefore narrow enough that turning send_note on is not itself a decision that needs a threat model.

The default scope is descent. An agent may note the runs its own run started, directly or transitively, and nothing else:

The target In the default scope
A run this run started Yes
A grandchild, or deeper Yes. Delegation is transitive, or inserting a middleman would widen the reach
A sibling (another run of the same parent) No
The run that started this one No. Descent has a direction
An agent running nothing of this run's No
Anything in another account No, and not as a scope question: a foreign id is simply not found

Lineage is the key because it is the only relationship here that Preloop can verify instead of accepting from the caller. parent_execution_id is written by the platform when a child run is created; "we are on the same team" is a claim an agent could simply make. The tool takes no lineage argument: the calling run is read from the authenticated context.

A caller with no lineage can note nothing. A top level run that started no children, and an enrolled agent that is not running inside an execution at all, both have an empty descendant set, so every target is refused. That is the case that makes the default safe: no lineage is no reach, rather than no restriction.

A refusal is a structured answer the model can act on, naming the scope the call would have needed, and each one has its own code:

Reason code What happened
note_scope_out_of_scope The target exists in the account but does not descend from the calling run
note_scope_no_lineage The call carries no execution lineage, so it reaches nothing
note_scope_denied_by_rule A tool access rule denied it
note_scope_grant_unavailable The rule that would have granted it asked for approval, or could not be evaluated. Scope widening fails closed

Every refusal writes one agent.note_scope_denied audit row carrying that code, the target, the calling run and the rule that decided, so "my agent says it cannot reach that run" is answerable from the record.

Granting a wider scope

Wider than descent is a grant, not a setting. When the default refuses, the call is put to the same tool policy path every tool call already goes through, as a rule evaluation on send_note, and only a rule that explicitly allows it is a grant: the default allow that a tool with no rules returns is not, or enabling the tool would quietly mean "note anyone". Add the rule on the send_note tool in the console, or through the tool access rule API:

tool:       send_note
condition:  has(args.note_scope) && args.note_scope == "account"
type:       cel
action:     allow
priority:   20

Rules are evaluated in priority order and the first match decides, exactly as for every other tool, so a deny placed above a grant wins. Disabling or deleting the grant takes the reach away on the next call: there is nothing to restart and no cache to clear.

Write the has() guard. The scope facts below exist only at this evaluation, and a CEL expression that reads a key the plain tool call does not carry fails that call closed. A simple condition needs no guard: a missing key is a non-match there.

These bindings are what a rule sees. Every one of them is what the platform worked out, never anything the calling agent asserted:

Binding Value
args.note_scope account, the scope being asked for
args.note_default_scope descendants
args.note_target_relation self, ancestor, same_tree, unrelated or no_lineage
args.note_author_managed_agent_id, args.note_author_execution_id Who is asking, and from which run
args.note_target_managed_agent_id, args.note_target_runtime_session_id, args.note_target_execution_id What it wants to reach
args.text, args.agent_id, args.runtime_session_id, args.execution_id text is the caller's. On the grant path the id keys are the target as the platform resolved it, under the names the tool uses, and can be present even when the caller never named them. Key on the note_* facts, which name author and target unambiguously.

The grant evaluation uses the same subject context as the preceding send_note call: the caller's api_key_id, the caller's runtime_session_id, and the rest of that chain. Target identity lives only in the note_* facts, so an API-key-scoped rule is not skipped on the grant path and a rule against runtime_session_id still means the caller. Top-level execution_id in the rule context is the author run only on grant evaluation. On a plain send_note call it is unbound.

So a grant can be narrower than "anyone". args.note_target_relation == "same_tree" lets runs in one delegation tree note each other and nothing outside it; args.note_author_managed_agent_id == "..." grants one coordinator the account and leaves every other agent on the default.

There is no scope wider than account. The account boundary is resolved before scope is considered and no rule can widen past it.

The honest part

A delivered note is untrusted input rendered into another agent's turn. The (agent) suffix and the auth="agent" attribute tell a reader where it came from, but a label is not a boundary against a model: a sufficiently confused agent can be talked into acting on a note it should have ignored. What the scope model buys is that only a run you started can put the words there, that the grant to do more is a rule somebody wrote and can revoke, and that both the send and the refusal are in the audit trail. What stops the note from being consequential is unchanged: it grants no permission, and every action the reader then takes still goes through the MCP firewall, the gateway and the approval policy.

How it is delivered

Two paths. Both put the note at a turn boundary, never inside a tool result and never mid-stream.

Gateway (primary, any harness). Every governed model call passes through the gateway, which already knows the account, the managed agent and the runtime session. Immediately after the request policy runs, and before anything is sent upstream, a pending note is appended to the end of the conversation in the protocol's own shape:

Protocol Where the note lands
OpenAI chat completions A trailing user message
OpenAI Responses A trailing input entry with an input_text part, and the normalized message list
Anthropic messages One more text block on the trailing user message, after any tool_result blocks, or a new user turn when the conversation ends on the assistant. User/assistant alternation is preserved
Gemini Through the Responses path it already translates to

Streaming and non-streaming entry points behave identically: the note goes in before the stream opens.

Hook (agents that bypass the gateway). The permission hook your harness already runs carries the note back with the decision, so a note costs no extra round trip: POST /api/v1/agents/permission-check answers with an operator_note field holding the rendered block, or null when there is none, which is almost always. Preloop stamps delivery_channel = hook; the text, the label and the note ids are the same ones the gateway path delivers.

The second hook route is POST /api/v1/agents/notes/pending. It authenticates with the managed-agent runtime bearer token and returns the claimed notes three ways at once: the raw envelopes, the rendered block, and a ready notifications/claude/channel event. channel is one of hook, claude_channel or claude_message; gateway is recorded only by the gateway path. Call it from the hook or bridge process, never from the model. Claiming is the delivery: what it returns is marked delivered, audited and evented before it leaves.

Harness What fires How to pick the note up
Claude Code PreToolUse, installed by preloop agents onboard --approvals operator_note on the permission-check response, written into hookSpecificOutput.additionalContext
Claude Code (channels) An MCP channel server you run POST /agents/notes/pending, then push channel_event as notifications/claude/channel. The harness wraps our block in its own <channel source= severity=> tag
Claude Code (cross-session messaging) A bridge process holding CLAUDE_CODE_MESSAGING_TOKEN, with crossSessionInbound: accept on headless -p workers POST /agents/notes/pending, then post text to the session inbox socket. The harness delivers it between tool calls
Codex CLI PreToolUse and PermissionRequest operator_note on the permission-check response, written into the PreToolUse hookSpecificOutput.additionalContext. PermissionRequest has no field for it, so a note claimed there rides the next PreToolUse
Cursor CLI beforeShellExecution, beforeMCPExecution, preToolUse operator_note on the permission-check response, written into the preToolUse additional_context on allow and deny. The two before* hooks have no field for it, and on this build preToolUse collects additional_context only on allow and deny, so a note claimed on ask also rides the next carrying call
OpenCode tool.execute.before, via @preloop-ai/opencode-plugin operator_note on the permission-check response
OpenClaw, Hermes Gateway path only, no hook needed Trailing message in the model request

For the Claude Code transports, Preloop supplies the text, the identity and the record; the harness supplies the last hop.

The permission hook renders the block itself for Claude Code, Codex CLI and Cursor CLI: nothing to install beyond preloop agents onboard --approvals, and a turn with no pending note produces exactly the response it produced before. Each field above was read from the installed harness, which is why they differ: only some hook events have a field that reaches the model at all. Codex accepts additionalContext on PreToolUse and rejects any unknown field on a PermissionRequest response; Cursor keeps additional_context for preToolUse on allow and deny and strips it from beforeShellExecution and beforeMCPExecution. On this build preToolUse collects additional_context only on allow and deny, so a note claimed on ask also rides the next carrying call. A note claimed by one of those hooks is held for its session and written into the next tool call's carrying hook, once, which is one tool call later and still a turn boundary. The versions each shape was captured from are recorded in cli/internal/cmd/testdata/operator-notes/.

The channel and inbox bridges remain a follow-up: they need a process the operator runs, not a hook Preloop already installs.

A note whose harness fires no tool call and makes no model call is not delivered, and its state stays pending until it expires. There is no path that pushes into a process that is not asking Preloop anything.

What the model sees

<operator-notes count="1" source="preloop-control-plane">
The block below is an instruction from the human operating this agent,
delivered out of band by the Preloop control plane at a turn boundary. ...
<operator-note id="a1b2c3d4e5f60718" from="Ada Lovelace" auth="jwt"
               at="2026-09-10T09:14:02+00:00">
Deploy to eu-west-1, not us-east-1.
</operator-note>
</operator-notes>

That framing is for a human-authored delivery and stays byte-identical to what shipped before send_note. A block whose every note is auth="agent" uses a different sentence: the named agent does not hold the permission to stop this run. A mixed block (human and agent notes in one delivery) says so, and still treats only the human-authored elements with the operator's stop-authority. The per-note from and auth attributes are unchanged in every case.

Preloop stamps every attribute. The sender authors only the text inside the element, and a body containing the literal characters of one of these tags is escaped on the way out, so no note can forge another note's identity and no note can close the block early. Tool output cannot forge a note at all, because tool output never travels this path.

This is the honest form of the prompt-injection argument. The label is not a security boundary against the model: a model can ignore a note, and a sufficiently confused one can be talked out of trusting it. What the label does buy is that everything an attacker would need to impersonate an operator (the tag, the identity, the timestamp, the note id) is written by Preloop from an authenticated session, so an injected string in a fetched page can claim to be an operator note and will still arrive escaped, inside a tool result, on a channel that no operator note ever uses. The real boundary stays where it always was: the note grants no permission, and every consequential action the agent then takes still goes through the MCP firewall, the gateway and the approval policy.

Several pending notes ride one block, oldest first, up to five. That is what a human who typed twice meant, and it removes the window where a second delivery arrives with no idea the first one happened.

Message shape

Each note is stored and returned as an A2A message, so a future A2A endpoint carries exactly these notes with no second schema:

{
  "kind": "message",
  "role": "user",
  "messageId": "a1b2c3d4e5f60718",
  "contextId": "<runtime session id>",
  "parts": [{"kind": "text", "text": "Deploy to eu-west-1, not us-east-1."}],
  "metadata": {
    "preloop.ai/kind": "operator_note",
    "preloop.ai/noteId": "a1b2c3d4e5f60718",
    "preloop.ai/managedAgentId": "0b0d...",
    "preloop.ai/author": {"userId": "...", "display": "Ada Lovelace", "authMethod": "jwt"},
    "preloop.ai/createdAt": "2026-09-10T09:14:02+00:00",
    "preloop.ai/expiresAt": "2026-09-11T09:14:02+00:00"
  }
}

role: user is deliberate: A2A reserves user for the client side of a task, and an operator note is a human turn, not the agent's own output.

Exactly once

A note is marked delivered before the request that carries it leaves Preloop, by an update guarded on the note still being pending. Two concurrent turns cannot both claim it; the loser delivers nothing rather than sending the instruction twice. A retried upstream attempt re-enters nothing, and a client that replays the whole request finds no pending note.

The failure this trades away is a note marked delivered into an upstream call that then failed. That is the better failure of the two. A second delivery would arrive with no idea the first one happened, and the sender can see the delivery state either way and resend deliberately.

Who can send, and what is recorded

Sending, listing and cancelling take control_managed_agent, the same permission that lets the caller stop the agent. If you can kill it, you can steer it and see the notes; if you cannot kill it, you cannot put words in its context or read them. Account owners and superusers hold it implicitly. Viewers get 403 with the required permission named, on Enterprise and on the open-source build alike.

A note never crosses an account. Every target is resolved with an account-scoped query, so an id from another account is a 404 and can never become a delivery, and the candidate query used at delivery time is itself bounded by the account.

Limits: 4096 characters per note, 20 notes per author per agent per hour (or per session, when the target has no managed agent). An agent author counts against the same ceiling, keyed on the authoring agent. Note bodies are stored in the clear, exactly as approval comments are, because both are operator text that has to be readable in the audit trail and in the timeline. Do not put secrets in a note; use the credential store.

Two audit actions, both written in the same transaction as the thing they describe:

Action Written when
agent.note_sent Before the API tells the author it worked
agent.note_delivered Before the request carrying the note leaves Preloop
agent.note_scope_denied Instead of a note, when an agent's target was out of its scope

The same two arrive as webhook events, agent.note_sent and agent.note_delivered (see webhooks), and each delivery is written to the runtime session timeline where it landed, with its channel and turn index, so the execution view shows the note in the stream of what the agent was doing when it arrived.