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¶
No extra model input tokens 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.
Native model-input plugins also use bounded note API requests at the model boundary. These add a network round trip even when the inbox is empty, but no extra model input tokens. Session registration is cached for the current run.
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. Track a receipt or list the notes for a target:
preloop notes status <note-id> --json
preloop notes list --session <session-id> --state claimed
preloop notes list --agent <agent-id> --json
preloop notes list --execution <execution-id>
preloop notes send --session <session-id> --wait 5m "Run the final checks."
status prints state, channel, creation time and delivery or terminal time.
list takes exactly one target and optionally filters by --state. Both accept
--json for the receipt fields, including the author and transition timestamps.
Against an older server, a status-route 404 prints "server does not support note
status, or the note was not found in this account"; the same response cannot
distinguish a missing note from an unsupported route.
send --wait polls only that note's status, every two seconds, for at most the
positive duration. Exit codes are 0 for delivered (or acknowledged), 3 for
undeliverable or expired, and 4 for timeout. Other errors exit 1. With --json,
waiting returns the final receipt as one JSON document; without waiting it
keeps the note-id-and-target output. Delivery means the carrying output was
written, not that the model followed the instruction.
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:
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. A durable enrollment
identity is not a live harness conversation: notes sent with agent_id stay
agent-scoped until an actual conversation claims them. Use runtime_session_id
(or notes send --session) to pin a note to one particular run. expires_in_seconds
overrides the 24 hour default; a note nobody delivered in a day is stale advice,
and expiring visibly beats rotting silently.
To brief a future run while other sessions are still open, send
{"agent_id": "<agent UUID>", "next_session": true, "text": "Run the checks."}.
Only a session started after the note was sent can claim it. next_session
requires agent_id; omitting it preserves current-or-next delivery. The receipt
keeps next_session: true after delivery and names the session that claimed it.
GET /api/v1/operator-notes?agent_id=...(orruntime_session_id,execution_id) lists notes newest first with their delivery state and receipt timestamps. Addstate=pending(or another public state) to filter before the result limit is applied. Combineagent_idandruntime_session_idto filter an agent's history to one conversation;limitandoffsetpage the results with a matchingtotal.GET /api/v1/operator-notes/{note_id}reads one receipt with the same account and permission checks; foreign and missing ids both return 404.POST /api/v1/operator-notes/{note_id}/cancelwithdraws 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 can use the same account-scoped endpoints above.
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.
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):
- 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 itsparent_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. - The id at spawn. SessionStart writes
~/.preloop/sessions/<external_session_id>.jsonwith theruntime_session_id,started_at,agent_kindandcwd, and in non-interactive mode (claude -p) prints one line to stderr:preloop runtime_session_id=<id>. - Discovery. The
list_sessionsbuiltin (off by default, enable it likesend_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 assearch_sessionsscopeaccountand 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 policy allows self and descendants. New accounts and existing
accounts whose startup backfill succeeds receive editable tool access rules for
send_note. A failed account is logged separately and retried on the next
startup; its existing rules remain in effect until seeding succeeds:
| Target | Default decision |
|---|---|
| The calling run, a child, or a deeper descendant | Allow |
| Another run the sender's human can control from the console | Require that human's approval |
| A run the sender's human cannot control | Deny |
| Anything in another account | Not found |
Preloop resolves execution and runtime session lineage before evaluating these
rules. The PreToolUse hook supplies preloop_caller_session_id; the server only
accepts a session owned by the calling credential's principal. A session-bound
credential cannot switch its session. See Spawning workers
for the hook and preloop spawn setup.
Approval uses the existing workflow service and push/email notification channels.
An asynchronous workflow returns pending_approval; get_approval_status delivers
the note once approved. Decline and expiry write no note. The request records the
resolved target, relation and human permission facts in its rule context.
The request's token link authorizes that bound human's approve or decline
decision without requiring sign-in. Automatic approval bypasses do not decide
these requests. A sender can queue at most three live approvals per target;
pending or declined requests do not spend the hourly note delivery allowance.
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 approval workflow is unavailable, or policy could not be evaluated. Scope widening fails closed |
note_approval_rate_limited |
Three live note approvals from this sender are already pending for the target |
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¶
Every target is evaluated by the standard tool policy path after resolution.
An explicit allow rule can grant wider access without approval: 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.
The scope facts below are available on every send_note policy evaluation.
A has() guard also makes exported rules safe in simulations without scope facts.
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.same_owner |
Author and target agents have the same human owner |
args.caller_user_can_steer_target |
The sender's human passes the console operator-note permission check |
args.note_target_relation |
self, descendant, 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. event names the harness lifecycle point that asked
(session_start, stop or pre_tool_use) and is stamped on the delivery, so
a Stop rescue is distinguishable from the ordinary next-tool-call path. Call it
from the hook or bridge process, never from the model. Gateway and carrying
hooks record claim and delivery together. A non-carrying hook uses
defer_delivery=true and gets a claimed receipt. After writing the block into
carrying output, it posts /api/v1/agents/notes/ack with note_ids and the
harness session_id. Receipt failures do not change the tool decision. Foreign,
unclaimed and already finished ids are silently ignored.
| 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 (session start / stop) | SessionStart, Stop and SessionEnd, installed by preloop agents onboard --approvals |
SessionStart (and the first UserPromptSubmit) pulls /agents/notes/pending with event=session_start and returns hookSpecificOutput.additionalContext, so a note sent before the run starts lands before the first tool call. Stop pulls with event=stop and, when something is pending, blocks the stop once with the rendered block as the continuation reason, so the model reads it and may act. SessionEnd posts /agents/session-end, which marks a note whose only session ended undeliverable instead of letting it expire |
| 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 | SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, Stop, SessionEnd |
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. Codex 0.162.1 supports the same startup context, bounded Stop continuation and SessionEnd receipt shapes as Claude Code |
| Cursor CLI | sessionStart, beforeSubmitPrompt, beforeShellExecution, beforeMCPExecution, preToolUse, stop, sessionEnd |
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. Startup uses additional_context; stop uses followup_message on completed turns with a maximum of three continuations, respecting loop_count. Headless --print runs fire no stop (see Cursor headless runs). beforeSubmitPrompt registers the session without claiming notes; sessionEnd reports the native session id |
| OpenCode | experimental.chat.system.transform, session.deleted, via @preloop-ai/opencode-plugin |
OpenCode 1.18.32 supplies a session id and mutable system array before each model request. The plugin registers the session, claims with defer_delivery=true, appends the rendered block and acknowledges its note_id values only after append succeeds. Failed acknowledgments are retried before another pull. session.deleted reports an end; session.idle remains resumable |
| Copilot CLI | sessionStart, sessionEnd; native preToolUse is non-carrying |
Session lifecycle commands register native sessionId and report its end alongside usage hooks. Native tool checks leave notes pending; no verified model-visible context or continuation field is installed |
| Devin CLI | SessionStart, UserPromptSubmit, native permission hooks |
Session registration only. Permission hooks have no verified note carrier; notes stay pending. Managed runner termination supplies end receipts |
| Pi, Nanobot | Gateway model request; native lifecycle plugins | Native tool gates leave notes pending. Notes can arrive on the next model request routed through Preloop; the plugins supply native session identity on model requests |
| DeepSeek Harness | Native pre-step middleware and llm/stream observer |
The plugin registers the native session and appends notes as SDK-supported logged user messages. It acknowledges only when the exact note appears in the frozen history admitted to a model call. Its awaited teardown effect reports session end. Native tool gates leave notes pending; gateway routing alone does not supply per-request native session headers |
| Hermes | pre_llm_call, pre_api_request, optional on_session_finalize |
Turn-prologue context carries notes; an exact witness in the sanitized API request confirms admission. Notes arriving mid-turn wait for the next turn. Per-turn on_session_end releases unadmitted claims without ending the session; only supported on_session_finalize reports a hard close |
| OpenClaw | before_prompt_build, llm_input, diagnostic model-call events, session_end |
Notes enter prependContext; exact input and matching actual-call diagnostics confirm admission. Preflight rejection cannot acknowledge delivery. Native notes require SDK prompt-injection and conversation-access permissions and diagnostic hooks; hosts without them keep notes pending. Only authoritative new/reset/deleted ends terminalize receipts; idle, compaction and shutdown remain resumable |
The native capabilities above are checked against Codex CLI 0.162.1 embedded
hook schemas, Cursor's hook reference
(Cursor Agent version 2026.10.01-e373342 observed), and OpenCode 1.18.32's
plugin SDK, DeepSeek Harness SDK 0.1.5-rc.2, Hermes revision
8f817911723e3ec13e7f47fc022fdcc9e2149354, and OpenClaw v2026.6.11.
Fixture and loopback harness tests establish protocol behavior;
real provider smoke results are tracked in #1637. Re-onboard after updating the
CLI or runtime plugin to install the lifecycle commands. Usage-hook removal
preserves the independently installed lifecycle commands; offboarding removes
both.
Other supported onboarding targets have narrower transport capabilities:
| Target | Operator-note boundary |
|---|---|
| Gemini CLI | Next model request routed through the managed gateway; no native lifecycle note hook is installed |
| VSCode / Copilot | A manually configured BYOK gateway route can carry notes; MCP onboarding alone does not inject context |
| Claude Desktop | An explicitly configured supported gateway route can carry notes; MCP onboarding alone does not inject context |
| Cursor/Windsurf IDE | Cursor native hooks are covered above where emitted; Windsurf has no installed note carrier |
| Antigravity | MCP firewall integration only; no model route or native note carrier |
| Browser-use, Skyvern | Import/observation adapters; importing activity does not inject instructions into a running vendor session |
A gateway route must be used by the actual model request to deliver a note. MCP tool access, an approval decision, and a usage event alone do not place text in the model context. A managed runner's authoritative termination and the server's reconciliation of ended sessions supply terminal receipts independently of native hook support (#1636). A silent or idle live session is never treated as ended solely to produce a receipt. Remaining native carriers and gateway session-identity limitations are tracked in #1638 and the adapter work
1489/#1494.¶
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 would never be
delivered. On Claude Code two more hooks close that gap: a note sent before the
run starts rides SessionStart, and a note that arrives after the last tool
call is rescued by Stop, which keeps the headless run alive for one more step
so the model can read it. At most three Stop blocks are spent per session, so a
stream of notes cannot pin a worker in a loop; past that cap the note waits and
SessionEnd marks it undeliverable rather than letting it sit pending until
its 24h expiry and look like the agent ignored it. A note addressed to the
agent with no session is untouched by a session ending: it keeps waiting for
the agent's next run.
onboard --approvals installs lifecycle hooks for Claude Code, Codex CLI and
Cursor CLI. Claude Code and Codex use Stop, SessionStart and SessionEnd;
Cursor uses stop, sessionStart and sessionEnd. Installing a hook does not
guarantee that every runtime mode emits it: the Cursor headless limit is
documented below. The Claude Code input and output shapes for these events are
pinned in cli/internal/cmd/testdata/operator-notes/claude-code-lifecycle.json.
A Stop block is only spent when stop_hook_active is false, which Claude Code
sets on the stop that follows a block. A headless claude -p run therefore
continues for at most one extra model step per stop, and a note that arrives
during that step waits for SessionEnd. If the hook names a run the caller's
credential never registered, session-end changes nothing. There is still no
path that pushes into a process that is not asking Preloop anything.
Cursor headless runs have no stop rescue¶
Cursor CLI 2026.10.01-e373342 in headless --print mode (default and ask
mode) emitted sessionStart and sessionEnd but never invoked the configured
stop hook (#1684). In headless runs a Cursor note therefore reaches the model
at startup or at a tool call, and the session end is reported authoritatively,
but there is no verified stop rescue: a session note that arrives after the
last tool call stays pending until sessionEnd, which marks it undeliverable
with reason session_ended. It is never reported as delivered. Notes addressed
to the agent rather than the session wait for its next run. Onboarding still
installs the stop hook, because Cursor documents it; interactive mode was not
tested and this evidence does not cover other Cursor versions.
preloop agents discover and preloop agents status print this limit on an
Operator notes: line for Cursor. The reproduction is in the
verification guide.
Delivery receipts¶
| State | Meaning | Receipt time |
|---|---|---|
pending |
Waiting for a carrying call or hook | created_at |
claimed |
A hook has spooled the block or a native carrier is awaiting model-input confirmation | claimed_at |
delivered |
Included in the gateway request, carrying hook output, or admitted native model input | claimed_at, delivered_at, delivery_channel |
acknowledged |
The harness recorded the model's acknowledgment | delivered_at, acknowledged_turn_id |
expired |
An uncarried note passed its TTL | expired_at |
cancelled |
The operator withdrew a pending note | cancelled_at |
undeliverable |
The only target session ended, or the local spool dropped the block | undeliverable_at, undeliverable_reason |
undeliverable_reason is session_ended or spool_dropped. A session end sweeps
pending and claimed notes for that exact session. Agent notes that still have
no session keep waiting for the next run. Existing rows keep their state and
have null values for the new receipt columns. The console shows the state and
available transition times, with undeliverable distinct from expired.
The API also reconciles notes whose runtime session is already recorded as ended, regardless of harness. This catches runner offline/idle termination, kill-switch termination, usage-import session-end events, and gateway idle session rollover. The background pass runs every minute and handles up to 100 ended sessions per pass; a backlog or unavailable API can delay receipts. A late hook cannot claim notes for a session already recorded as ended. Reconciliation does not end a session because it is silent, asleep, or briefly disconnected. Without an authoritative termination event, a note keeps waiting until delivery or its expiry.
See Operator note verification for isolated API, receipt and installed-harness checks, including which evidence uses simulated providers and which requires a real provider account.
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.
Four audit actions, all 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 |
agent.note_undeliverable |
When session-only notes can no longer arrive (session_ended), or a local spool drops a claimed block (spool_dropped) |
Three arrive as webhook events, agent.note_sent, agent.note_delivered and
agent.note_undeliverable (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.
For spawn-time aliases, parent lineage, and session id handoffs, see Spawning workers.
The console discovers live note targets with
GET /api/v1/operator-notes/session-targets?agent_id=<agent UUID>. The response
contains sessions and total, paged with limit and offset. Each target
includes its ID, alias, harness (session_source_type) and start time.
Enrollment identity placeholders and ended sessions are excluded. Sending a
note still resolves its target again with the same account and permission
checks as send_note.