Operations
The Operations extension (version 1) gives actions an exact, durable
approval. A company proposes an operation (from an API's 202, an MCP tool's
structuredContent, a conversation message, or its website) and performs it
only once it is confirmed at {endpoint}/{operation_id}/confirm. The
OperationsClient is the personal agent's side.
Negotiate​
from personal_agent_protocol import OperationsClient, approve, operations_endpoint
endpoint = operations_endpoint(discovered.document, my_client_metadata)
if endpoint is None:
... # one side does not list operations version 1: do not use Operations
operations = OperationsClient(transport=DpopHttpClient(reactor=reactor, http_client=http))
A company answers an agent that did not negotiate the extension with
extension_required; the client puts the extension name in error.details.
Approve exactly what the user saw​
show_to_user(operation.summary, operation.terms) # the host's own interface
approval = approve(operation) # revision + digest of summary and terms
outcome = await operations.confirm(endpoint, operation, approval=approval, token=token, **who)
if outcome.status == "terms_changed":
show_changes(outcome.operation, outcome.changed) # e.g. ("summary", "amount.amount")
# ask again; the old approval cannot confirm the new revision
const approval = await approve(operation); // async: the digest uses Web Crypto
const outcome = await operations.confirm(endpoint, operation, { approval, token, tenantId, pairwiseUserId, issuer });
approve()is called by the host when the user approves in its interface, never because a model or a chat message said so.- A confirmation for any other revision, or for different terms, is refused
locally (
approval_mismatch). Only the latest revision can be confirmed at the company; an older one isterms_changed, returned with the new revision and the changed term paths. - The company's
result.summaryis returned as written: show it, do not paraphrase it into something it did not say.
Recover, never re-propose​
A confirmation whose answer is lost may have been performed. The client reads
the same operation back by ID: a final or in_progress state is the outcome;
still proposed means it did not arrive (confirm_not_received) and the host
may retry. Confirming is idempotent at the company, so a retry returns the
same record. in_progress may carry retry_after; poll with read().
Standing permissions​
A user may allow some operations in advance. A StandingPermission names one
company, account, and action, with limits on term paths (TermLimit: a
decimal maximum or a set of allowed values), an expiry, and optional uses.
outcome = await operations.confirm(
endpoint, operation,
standing=("Northwind Bank", account, "payments.transfer"),
token=token, **who,
)
The client asks the Reactor pap.standingPermissionStore
(StandingPermissionStores in TypeScript) to reserve a use. A permission
covers a revision only when every limit's term is present and within bounds,
it has not expired, and it has uses left. The use is committed when the
company confirms and released when it refuses. Nothing covers it:
no_standing_permission, ask the user. A revision with
user_approval_required is never confirmed this way, and a company that
insists answers user_approval_required (approval_required).
InMemoryStandingPermissionStore is for examples and tests. A production
store is durable and atomic, and the act of granting a permission deserves its
own exact approval: in the two companies example a
standing permission is itself an operation the user approves.
Cancel​
cancel() turns a proposal into cancelled; an operation in progress stops
if the company can stop it. Closing a conversation does not cancel anything.
Show what is asked: PapConfirm​
A host that asks the person in a page, not only in its own interface, sends
the operation as a Reactor display:
PapConfirm (personal_agent_protocol.ui, the ui extra), of type
application/vnd.datalayer.pap.confirm+json, version 1. An MCP tool puts it
in its result's _meta["reactor.ui/display"]; any page with a renderer for
the type draws the card, and a page without one shows its text.
from personal_agent_protocol.ui import PapConfirm, PapSessionShown
display = PapConfirm(
company="shop.example",
summary=operation.summary, # the company's words, verbatim
terms=operation.terms or {},
operation_ref=operation.operation_id,
revision=operation.revision,
does="buy", # what it does, as the application's Rules class it
session=PapSessionShown.of(session_id, signed_in=True),
expires_at=operation.expires_at,
only_the_person=operation.user_approval_required,
)
meta = display.tool_meta() # an MCP result's `_meta`: the page draws the card
said = display.text() # what a page without a renderer, a transcript and a model read
text()is shop.example asks you to confirm: … (version 1), and Changed since version 1. after it whenwasnames the revision a newer one replaced.- The Session is a one-way reference (
PapSessionShown.of: 12 hex digits of the SHA-256 of its ID), never its ID or token, and only the page sees even that:model_view()tells a model the company's words, the terms, what it does, the revision and whether the person is signed in there. - The buttons are the host's own. Approving still goes through
approve()in the host, never because a model saw the display.
On the page, the TypeScript SDK types the same display and checks it:
import { isPapConfirm } from "@datalayer/personal-agent-protocol";
const display = result._meta["reactor.ui/display"];
const asked = isPapConfirm(display) ? display.data : null; // PapConfirmData, or nothing to draw
isPapConfirm refuses another type or version, a missing or unknown field,
and a Session given by anything but its reference. Both SDKs are checked
against one committed example, protocol/fixtures/pap-confirm-display.json.
Show what it came to: PapOutcome​
Once an operation is confirmed, the host shows what the company says it came
to, in the company's words: PapOutcome (personal_agent_protocol.ui), of
type application/vnd.datalayer.pap.outcome+json, version 1, made with
PapOutcome.of(company, operation, approved_by=...) from the operation the
company answered.
resultis the company's finalresult.summary, verbatim — a failed operation's partial effects included — never a model's paraphrase. A final state (succeeded,failed,cancelled,expired) requires it.- A confirmation sent whose answer was lost reads back as
proposed: the display saysin_progresswith no result, The outcome is not known yet, until reading the operation again tells it. It is never sent twice. text()is Done at shop.example: … (version 2), Failed at …, Cancelled at …, Expired at … or Still in progress at ….
The TypeScript SDK types it (PapOutcomeData) and checks it with
isPapOutcome, against the committed example
protocol/fixtures/pap-outcome-display.json.
Security boundary​
- Conversation text never confirms an operation, whoever wrote it.
- The model may explain a proposal and its changes; the approval is the user's, in the host's interface, bound to one revision.
- Tokens and proofs stay host-only; errors carry codes only.
See the runnable Operations example and the upstream Operations extension.