- Key the idempotency record on (authenticated principal, requestId), never requestId alone (I3, C2) - Rule empty arrays a second spelling of absence: optional arrays are omitted when empty, a present [] is malformed (I6) - State the shared publish outcome (eventId/kind/createdAt) once at message.post - Define the no-answer treatment for a request whose requestId is itself unusable - Name the unit behind 'characters' (Unicode scalar values) - Correct the storage.address example kind to the agent-memory kind - Say out loud that read completeness on the wake path is trusted, not verifiable: silent mention withholding is indistinguishable from quiet Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
33 KiB
The Agent Broker: A Specification
draft
Abstract
This document specifies the protocol by which a Buzz agent that holds no secret key participates in Buzz anyway: reading its channels, answering a mention, reacting, maintaining its profile, addressing its encrypted memory, and creating the agents it needs. It does so by asking a broker host — a process that does hold the key — to act on its behalf, one named operation at a time.
The broker exists so that an agent's identity can be separated from its signing key: the key stays under separate custody while the agent runs somewhere ephemeral, shared, or less trusted. The protocol is Buzz-defined and lives in Buzz's shared client library, but it is not a NIP: the relay never sees it. A broker host does ordinary relay work as an ordinary client, and nothing about the relay protocol changes.
Two design decisions shape everything below. First, the broker exposes a closed set of named operations rather than a "sign these bytes" primitive, so that a host can reason about what is being asked, not only who is asking. Second, the request body never names its own authority — no requester, owner, or scope field exists — so the host's authentication of the session is the only thing that decides who is acting.
We state six invariants — no secret crosses the wire, authority comes from the session, never the body, retry means identical bytes, reads return signed events, a verdict is only ever read against the request it answers, and absence has one spelling — and argue each from the rules.
Scope and Non-Goals
This document specifies the contract between an agent and a broker host: the request and response envelopes, the nine operations and their argument and outcome shapes, the result and error model, and the HTTP binding. It deliberately does not specify:
- The host. How a host authenticates a credential, decides authorization, stores idempotency records, executes an operation against the relay, or caps the depth of agent-creates-agent chains. The host is a separate deliverable with its own specification; this document states what the host must accept and return.
- Signing, encryption, and relay authentication. These are mechanisms inside whoever holds the key. An agent says "post this message"; the host signs. There is no operation for "sign", "publish", "encrypt", or "authenticate to the relay", and the names are reserved against ever appearing (§Operations).
- Authorization grants. There is no authorization field in any payload. One will be added, as a discriminated object, when a real grant format and verifier exist — not before, so that no field looks security-bearing while enforcing nothing.
- Secret-key custody. How a host holds keys, and whether an agent refuses to start when a stale local key is still present in its environment, are decisions for the host and the harness respectively.
- Transport confidentiality. The credential is the whole of an agent's authority. A host serving this protocol over anything other than a loopback socket or TLS is publishing that authority. The requirement is stated; the mechanism is the deployment's.
System Model
Three principals:
- Agent
A— a harness process running a Buzz agent. Holds its own public key and an opaque session credential handed to it at startup. Holds no secret key and may have no route to the relay at all. Everything it wants to do, reading included, it does by askingH. - Host
H— the broker. Holds the secret keys of the agents it serves and a connection to the relay. Authenticates each request by its credential, decides whether the requester may do what it asks, performs the operation as an ordinary relay client, and returns a verdict. - Relay
R— the Buzz relay. Sees onlyH, behaving as any client would.Rdoes not knowAexists and does not knowHis a broker.
The shape of every interaction is one round trip:
A ──── request (one operation, one idempotency key, bearer credential) ───▶ H
H: authenticate → authorize → validate → execute against R → verdict
A ◀─── response (succeeded + outcome | failed + error | indeterminate + error) ── H
Two axioms govern the whole design:
- (B1) Identity is public-key-only. The only identity anywhere in this protocol is a 64-character lowercase hex public key. No type, field, or message carries a secret key in either direction. This makes "identity separable from signing" structural rather than procedural.
- (B2) The body never asserts authority. No request names who is asking,
who owns what, or which scope applies.
Hderives all of that from the authenticated session. A body that could name its own subject would let any caller act as anyone.
From B2 follow three things a reader might otherwise expect and not find: a
mentions-only read cannot ask about a different identity's mentions; the
profile operation has no subject field, because only the requester's own
profile is addressable; and agent creation has no owner field, because the
owner is whoever H authenticated.
Invariants
Each invariant names the property, the rules that produce it, and what remains honestly outside the claim.
(I1) No secret crosses the wire. No payload in either direction has a
field that can carry secret key material. Every request and response type is
strict: an unknown member anywhere — at the top of the envelope, inside an
argument object, inside an outcome, inside each returned event — is a parse
failure, not a field that is quietly ignored. Agent creation returns the new
agent's public key, name, and channel and nothing else; the minted key stays
on H. The exact member set of every one of the eighteen argument and
outcome shapes is pinned by test, so a field cannot be added to any of them
without a reviewer touching the table that lists them.
Limits. A string field can physically hold secret text, so keeping secrets out of message content and error messages is host policy the contract cannot enforce. And nothing prevents a host from holding keys — that is its job. The invariant is that it cannot hand them over through this interface.
(I2) Authority comes from the session, never the body. Axiom B2, restated as a property of the wire: the request envelope has exactly six members (§Request) and none of them is a requester, owner, scope, relay, or credential. The credential travels in a transport header, opaque to the contract. Ownership of a created agent is implicitly the requester, which is what lets agents own agents while the chain of ownership still terminates at a human.
Limits. Binding a credential to a specific agent and conversation — so that
a leaked token cannot act as a different agent or outside the conversation it
was issued for — is H's responsibility. The contract documents that
expectation and deliberately cannot enforce it, because enforcing it would
require the very scope field B2 forbids.
(I3) Retry means identical bytes. A request is validated, normalized, and
serialized exactly once; the transport only ever sees that frozen form,
and the first attempt and every retry send the same bytes under the same
requestId. The durable idempotency record is keyed on the authenticated
principal and the requestId together, never on the requestId alone —
otherwise one caller reusing another's id would be handed the other's recorded
verdict, a cross-principal read that no authorization check would ever see.
H hashes the bytes it receives and compares that digest to the
one recorded under the same key: same key and same digest replays the
recorded outcome without re-running anything; same key and a different digest
is rejected as a request_id_conflict. Because there is no path by which a
client can re-render a request between attempts, a retry cannot drift and be
mistaken for a conflicting request. There is no client-computed digest field:
idempotency is decided host-side, and a caller-supplied digest would be a
claim H has to recompute anyway.
(I4) Reads return signed events. A read returns the relay's own signed
Nostr events, verbatim — id, pubkey, created_at, kind, tags, content, and
signature. A can verify authorship and content locally, with no relay and no
key, and therefore trusts H only for completeness (that nothing was
withheld) and for authorization. Reply ancestry and mentions are derived
from the signed tags rather than carried as sibling fields, so nothing in a
read result can disagree with the signature. Each event object is held to the
canonical seven members and nothing else, so I1 reaches inside events too.
Limits. Verification is available to A, not forced on it; whether to pay
for it and what to do when it fails is the caller's policy. And the trust in
completeness lands hardest on the wake path: a keyless agent polls its
mentions instead of holding a subscription, cursors are opaque and
host-issued, and pages end when nextCursor is absent — so a host that
silently withholds mentions from one sender is indistinguishable, to the
agent, from a quiet channel, forever. This document places that squarely on
the trust already extended to H: the host holds the key and could as easily
refuse to act; selective silence on reads is the same betrayal by a different
door, detectable only from outside the pair.
(I5) A verdict is only read against the request it answers. The only
response a caller can obtain is one that has been checked against the request
that produced it: the requestId correlates; a succeeded outcome is for the
same operation that was asked; identifiers the outcome echoes (channel,
pubkey, event id) match those the request supplied, compared as parsed
identities so that spelling variants never cause a false mismatch; a read
returns no more events than the request's effective limit; and the status does
not contradict its own error code (§Results). A response that fails any of
these is not a host verdict — it is treated as "no answer", from which
nothing can be concluded about side effects, rather than as a failure the
caller might act on.
Limits. An update or delete that targeted an agent by name asked for an
identity the caller cannot verify in the reply. That is inherent: H resolves
the name, and a rename may be the very thing the call performed.
(I6) Absence has one spelling. Every optional member means "absent" by
being omitted. null is never a legal value anywhere in this protocol, at
any depth, in either direction; a member present with the value null makes
the whole payload malformed. The same rule covers empty collections: an
optional array member is omitted when it has no elements, and a member present
as [] is malformed — otherwise absence would have exactly the second
spelling this invariant exists to forbid. A key may also appear at most once in any object.
The reason is that this contract decides meaning from absence — which members
a status admits, whether a read has a default page size — so absence cannot
have two spellings without one of them slipping past a check. This rule costs
the most for implementations in languages whose serializers emit null for an
unset field by default; §Conformance states the consequence plainly.
Protocol
Identities and spellings
Two identity forms appear throughout, and both admit several legal spellings in the wild. The protocol picks one and normalizes to it at every door, so a value is canonical whether it was constructed or parsed:
- A channel id is a UUID. Canonical form is lowercase and hyphenated. A
sender may use uppercase, the unhyphenated 32-character form, braces, or a
urn:uuid:prefix and will be read as having sent the canonical form. - A public key, event id, or d-tag is 64 hex characters. Canonical form is lowercase.
Without this, a caller's spelling of a channel and H's canonical echo of the
same channel would fail I5's correlation check for a correct answer. The
correlation check also compares parsed identities, so neither guard is
load-bearing alone.
Request
A request is a JSON object with exactly these six members, and no others.
Throughout this document … in an example marks a placeholder for a real
value; the three full envelope examples in §Results use values that parse.
{
"type": "broker_request",
"protocolVersion": 1,
"requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10",
"actionVersion": 1,
"action": "message.reply",
"args": {
"channelId": "5df7dfa8-e919-43df-8efd-f1dcb8af7071",
"replyToEventId": "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359",
"content": "On it.",
"mentions": ["d31d54af61c73c47053822d496ce341eac337252ee1aa7c41c9751fa3060d121"]
}
}
typemust be the literalbroker_request.protocolVersionis the version of this envelope. It is required; there is no "absent means 1" rule, because the protocol is unshipped and an unknown or missing version is rejected outright. This document is version 1.requestIdis the caller-chosen idempotency key, unique per logical operation: 1–128 bytes of printable ASCII with no spaces. It becomes part of a durable idempotency record and appears in audit logs, which is why its character set is constrained. A request whoserequestIdis itself unusable — missing,null, repeated, or malformed — is the one envelope error a host cannot answer with a correlated verdict; whatever it returns is uncorrelated by construction, and the caller treats it as "no answer" (I5), never as a verdict.actionVersionis the version of the operation's argument shape the caller wrote against. All nine operations are at version 1 in this document.actionis one of the nine wire names in §Operations.argsis the argument object for that operation. Its shape is fixed per operation and strict.
Operations
There are nine operations in version 1. Operations are the unit of policy: a
host can serve channel.read while refusing agents.create, and any later
policy or information-flow layer has a per-operation surface to attach to. The
cost of a closed set is that adding an operation is a change to this document
— which is the point; it makes a new capability a reviewable change rather
than a new use of an existing blank cheque.
Throughout, string fields are trimmed of surrounding whitespace before being
frozen into the request, with three exceptions: message content publishes
exactly as written; a cursor is opaque and nothing may alter it; and a
requestId is never trimmed because it may not contain whitespace at all. Length limits on names, prompts, and similar fields count characters
(Unicode scalar values — code points, not grapheme clusters and not UTF-16
units); limits on content, cursors, and request ids count bytes.
channel.read — the one read operation. It covers a whole channel, a
single thread, or the requester's mention feed, because those differ only by
filter, and a name per scope would split one permission — may this agent see
this channel — across three policy decisions.
{ "channelId": "…", "rootEventId": "…", "mentionsOnly": true, "cursor": "…", "limit": 50 }
channelId is required. rootEventId, when present, narrows the read to one
thread. mentionsOnly defaults to false and, when true, narrows to messages
mentioning the requester — the wake path for a keyless agent, which polls this
instead of holding a subscription. cursor is the opaque position returned by
a previous read; absent on a first read, which starts at the host's default
window. limit is 1–500; absent means the host's default page of 100, not
unbounded, so there is always a number the response can be held to. The
outcome is a page:
{ "messages": [ { "id": "…", "pubkey": "…", "created_at": 1787675388, "kind": 9, "tags": [], "content": "…", "sig": "…" } ],
"nextCursor": "…" }
messages are signed events in the host's declared order (I4). nextCursor
is present when there is more to read and omitted when there is not; a caller
learns to stop from its absence, never by comparing a page length against a
limit it may not have set. A cursor is 1–256 bytes of printable ASCII with no
spaces, issued by H, validated for shape only, and never parsed, compared, or
synthesized by A. This is deliberate: a timestamp cursor cannot page
safely when more events than limit share one second, and it would commit
every future host to one ordering strategy. H owns ordering and cursor
stability, including whether a cursor survives its own restart.
message.post — a top-level channel message.
{ "channelId": "…", "content": "…", "mentions": ["<pubkey>", "…"] }
content is required, non-empty, and at most 64 KiB. mentions is optional,
at most 50 public keys, and is what produces notification tags; it is
omitted when empty (I6). The outcome — shared by all four publishing operations
(message.post, message.reply, reaction.add, profile.set) — is the
published event's eventId, kind, and createdAt, all three host-minted:
{ "eventId": "…", "kind": 9, "createdAt": 1787675471 }
message.reply — a reply to an existing message. As message.post, plus
a required replyToEventId. Which event becomes the thread root is H's
job, derived from the parent's own tags.
reaction.add — a reaction to an existing message.
{ "channelId": "…", "targetEventId": "…", "reaction": "👍" }
reaction is intended to be an emoji or a :shortcode:; the contract checks
only that it is non-empty and at most 66 characters, and what counts as a
reaction beyond that is H's to decide. This is the one best-effort operation in version 1: a host may answer it
unsupported and the agent carries on unharmed (§Results). Non-essential
signed housekeeping must be skippable so an agent can run where it is
unavailable; reactions are that housekeeping.
profile.set — the requester's own profile metadata. No subject field
(B2).
{ "displayName": "…", "about": "…", "picture": "https://…" }
Each member is optional; at least one must be present, and absent members are left as they are — the host does not clear them. Limits: 120 characters for the name, 2,000 for the blurb, 300 for the picture URL.
storage.address — derive the relay address of one encrypted-memory
record. Deriving the address needs the secret this protocol exists to keep
away from A, which is why it is routed through the interface rather than
computed locally.
{ "slug": "mem/slice-c" }
slug is core or a mem/… path in the NIP-AE grammar, at most 255
characters (the grammar is ASCII-only, so bytes and characters coincide).
The outcome is addressing material only:
{ "authorPubkey": "…", "kind": 30174, "dTag": "…" }
The d-tag is a keyed hash of the slug, so it identifies the record without revealing the slug or the key that derived it.
agents.create — mint a managed agent owned by the requester.
{ "channelId": "…", "displayName": "…", "systemPrompt": "…",
"runtime": "…", "provider": "…", "model": "…", "respondTo": "owner-only" }
channelId, displayName (≤ 120 characters), and systemPrompt (≤ 20,000
characters) are required. runtime, provider, and model are optional
short scalars (≤ 300 characters) the host interprets; a host refuses a runtime
it cannot resolve. respondTo is owner-only or anyone; absent means the
host's owner-only default. There is no allow-list mode, because it would need
a pubkey list this shape does not carry, and a mode without its list would
mint an agent nobody can talk to. There is no owner field (B2, I2). The
outcome:
{ "agentPubkey": "…", "displayName": "…", "channelId": "…" }
— public identity only. The key that was just minted is not here and has no place to be (I1).
agents.update — patch a managed agent the requester owns.
{ "target": { "pubkey": "…" }, "displayName": "…", "systemPrompt": "…",
"runtime": "…", "provider": "…", "model": "…", "respondTo": "anyone" }
target names the agent by exactly one of pubkey or name, so a host
never has to decide which of two selectors wins. At least one other member
must be present. The outcome reports the agent's pubkey, its name after the
update, and updatedFields, in which H lists the wire names of the members
it actually changed, sorted. The client holds H to that only by convention —
it checks the name's shape and nothing about the list.
agents.delete — remove a managed agent the requester owns. Arguments
are the target selector alone; the outcome is the removed agent's pubkey and
name.
Reserved against. sign, publish, encrypt, decrypt, and relay
authentication are not operations and will not become operations. A
signing primitive would make the broker an oracle that can tell who is
asking but not what for, and the only policies it could express would be
"all" and "none". Naming intent — message.post — gives H something to
reason about; naming a mechanism — publish(event) — does not. A test pins
that none of those names resolve.
Deferred, not refused. presence.set and typing.set are not in version
- They are housekeeping a host may decline anyway, and adding them later is purely additive. Streaming reads are also deferred: reads are request/response, and waking on a mention is a polled mentions-only read.
Results and errors
A response is a JSON object with one of three shapes, discriminated by
status:
{ "type": "broker_result", "protocolVersion": 1, "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10",
"status": "succeeded", "action": "message.reply",
"outcome": { "eventId": "5ff391bf2c6d4e8a9b7c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a",
"kind": 9, "createdAt": 1787675471 } }
{ "type": "broker_result", "protocolVersion": 1, "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10",
"status": "failed",
"error": { "code": "unauthorized", "message": "agents.create is not permitted for this session" } }
{ "type": "broker_result", "protocolVersion": 1, "requestId": "a9f3c2e1-0b5d-4e8a-9c71-3f2b6d8e4a10",
"status": "indeterminate",
"error": { "code": "outcome_unknown", "message": "relay connection dropped after publish was attempted" } }
A succeeded response carries action and outcome and must not carry
error. A failed or indeterminate response carries error and must
not carry action or outcome. These are parse failures, not warnings:
"succeeded with an error" and "failed with an outcome" are unrepresentable,
and the strict reader applies I6 here too, so "outcome": null beside a
failure does not parse as a plain failure and skip the check.
An optional replayed: true marks a response that replays a previously
recorded outcome under I3. It is delivery metadata, not part of the stored
verdict, and a replayed response is byte-identical in its result to the
original. It is omitted when false.
The three statuses make three different promises. succeeded means the
operation completed and the outcome describes what happened. failed
promises that no side effects took hold — the caller may treat the
operation as never having happened. indeterminate promises nothing
and demands reconciliation: the caller must retry the identical bytes (which
H will deduplicate) or read state to find out.
Because a status and an error code are two statements about the same fact — whether side effects landed — they cannot be paired freely. The complete table:
| Code | Meaning | with failed |
with indeterminate |
|---|---|---|---|
invalid_request |
envelope or arguments failed validation | yes | no |
unsupported_protocol_version |
protocolVersion not supported by this host |
yes | no |
unknown_action |
action name unknown to this host | yes | no |
unsupported_action_version |
actionVersion not supported for this action |
yes | no |
unsupported |
host knows the action but does not offer it | yes | no |
unauthenticated |
credential missing, malformed, or rejected | yes | no |
unauthorized |
authenticated, but not permitted this action | yes | no |
request_id_conflict |
requestId reused with different bytes |
yes | no |
action_failed |
the action ran and reported a domain failure | yes | no |
outcome_unknown |
host cannot tell whether side effects occurred | no | yes |
internal |
unexpected host fault | yes | yes |
Every code but two names a fate H knows, so those are failed-only.
outcome_unknown is the code for not knowing, so it is indeterminate-only.
internal is the one code legitimately either: a fault before dispatch is a
known no-op, and the same fault mid-execution genuinely is not. A response
pairing a code with a status the table forbids is malformed (I5).
Two codes deserve a sentence each. unauthenticated is a host verdict,
delivered as failed, never as a transport error — it carries the promise
every failed carries, that the action did not run. Modelling it as a
transport error would throw that knowledge away and tell the caller to
reconcile something that provably never happened. unsupported is a normal
answer for a best-effort operation (reaction.add in version 1) and the
agent carries on; for any other operation it means the agent cannot do its job
on this host.
Error messages are for operators and must never carry secrets — no keys, no credentials, no decrypted payloads.
HTTP binding
POST /v1/action
Authorization: Bearer <opaque session credential>
Content-Type: application/json
<the frozen request body, verbatim>
One endpoint. The body is the request exactly as frozen under I3. The
credential is a bearer token A received at startup and can only replay; it
is not a key, not a signature, and not derived from anything A holds. It is
opaque to this contract.
Every verdict H reached comes back as a well-formed envelope with HTTP
200 — including failed, and including a rejected credential. The verdict
lives in status; a second copy of it in the status line could only ever
disagree with the first. A client must nonetheless attempt to parse an
envelope regardless of HTTP status, because a host or an intermediary may
map dispositions onto conventional statuses for observability or for
middleware that cannot read bodies. If a valid envelope is present, it is the
answer and the status line is decoration.
Only when no envelope can be parsed does the status matter, and then only
as detail for an operator. A proxy's 401, a 404 for a missing route, a 502,
a connection that dropped mid-request, or a body that claimed to be an
envelope and was not — all of these are transport failures: the request's
fate is unknown, nothing can be concluded about side effects, and the only
safe next step is to retry the identical bytes or reconcile by reading state.
An intermediary's 401 does not prove H never ran the action. Host verdicts
never appear as transport failures, and transport failures are never promoted
to verdicts.
Conformance
[C1] Client conformance — any agent-side implementation
- Sends only the nine operations named in §Operations; never attempts a reserved name.
- Validates and normalizes a request, serializes it once, and sends those
bytes on the first attempt and every retry under the same
requestId(I3). - Never places a requester, owner, scope, relay, or credential in a body
(I2). The credential travels only in the
Authorizationheader. - Treats a cursor as opaque: round-trips it verbatim, never parses, compares, or synthesizes one.
- Attempts to parse an envelope from every HTTP response regardless of status; treats a parseable envelope as the answer and an unparseable one as a transport failure, never as a verdict.
- Reads a response only after checking it against the request: matching
requestId, matching operation on success, matching echoed identities compared as parsed values, page size within the effective limit, and a status/code pairing the table permits (I5). A response failing any check is "no answer". - Treats
failedas "nothing happened",indeterminateas "reconcile", andunsupportedon a best-effort operation as a normal answer. - Omits every absent optional member; never emits
null; never repeats a key (I6). - May verify each returned event's id and signature locally (I4); decides its own policy when verification fails.
[C2] Host conformance — any broker host
- Accepts only the request envelope in §Request with exactly its six
members; rejects an unknown member anywhere, a
nullanywhere, a repeated key, an unknownprotocolVersion, an unknown action, or an unsupportedactionVersion— each asfailedwith the matching code. - Derives requester, owner, and scope from the authenticated session and nothing else (I2). Binds each credential to the agent and conversation it was issued for.
- Records, per authenticated principal and
requestId, a digest of the received bytes and the verdict reached; replays the recorded verdict withreplayed: trueon a matching digest and answersrequest_id_conflicton a differing one (I3). - Returns read results as the relay's signed events with exactly the
canonical seven members each; never a projection, never an extra member
(I1, I4). Honors
limitand the default page of 100 when it is absent; omitsnextCursorwhen there is nothing further. - Returns public identity only from
agents.create; never places key material, a credential, or a decrypted payload in any outcome or error message (I1). - Pairs status and code only as the table in §Results permits. Never answers
a rejected credential with anything but
failed+unauthenticated. - Returns every verdict as an envelope with HTTP 200; may additionally map statuses for intermediaries but never relies on the status line to carry the verdict.
- Omits every absent optional member; never emits
null(I6). Implementers in Go must setomitemptyon optional members; in Python must dropNone-valued keys before serializing; in any language must not assign optional keys unconditionally. - Serves the endpoint only over loopback or TLS.
Relationship to the Implementation
The reference implementation of this contract is the broker module in Buzz's shared client library. It is a contract only — the request and response shapes, the validators that enforce the rules above, and a client interface whose only implementation is a test double. It contains no host, no transport, and no signing. The work that follows it adds a host and then wires the harness to choose between holding a key locally and delegating to a broker; those land as separate changes and, where they change the wire, amend this document in the same change.
Where this document and the implementation disagree, the implementation is wrong and this document is to be amended if the disagreement was intentional.
Open Decisions
- Best-effort set. Version 1 marks only
reaction.addbest-effort. Whetherprofile.setshould join it — an agent can function with a stale profile — is a policy call that affects which hosts an agent can run against. Current position: no; a profile is identity-adjacent and a host that refuses it should say so loudly. - Stale local key. Should an agent configured for a broker tolerate, warn about, or refuse a secret key still present in its environment? This is harness behavior, not wire, and is left to the harness change that follows; the contract is silent on purpose.
- Grant format. When an authorization field is added (§Non-Goals), it
will be a discriminated object with a verifier on
H. Its shape is not designed here. - Depth of ownership chains. Agents may own agents (I2). Bounding the
depth is
H's policy; whether the contract should expose a code that distinguishes "depth exceeded" from a genericunauthorizedis open.
Summary
A keyless agent holds a public key and a bearer credential and asks a broker
host to perform one of nine named operations on its behalf. The body of a
request never says who is asking; the host's authentication of the session
decides that. Requests are frozen to bytes once and retried verbatim, so
idempotency is a property of the bytes rather than a promise. Reads return
signed events the agent can verify itself. A response is only ever read
against the request it answers, and a response that fails that check is no
answer at all. Nothing optional is ever null. No secret, in any direction,
has a field to travel in. The relay never learns any of this happened.