Skip to main content

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 is terms_changed, returned with the new revision and the changed term paths.
  • The company's result.summary is 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 when was names 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.

  • result is the company's final result.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 says in_progress with 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.