Skip to main content

Extensible pap CLI

The Python distribution installs an initial pap command line for non-secret configuration, company discovery, agent-identity inspection, and extension negotiation diagnostics. The executable is a small Typer host: its PAP command groups come from a Datalayer Reactor extension rather than being hard-coded into the host.

python -m pip install personal-agent-protocol
pap --help

The command groups are:

CommandPurpose
pap configShow, get, set, unset, and list non-secret profiles and value provenance.
pap companyDiscover and verify a company's PAP document.
pap agentVerify personal-agent metadata and public JWKS; import the agent's signing key.
pap extensionsList advertised PAP extensions and check one required major version.
pap sessionsstart, renew, show, and end Sessions (§4.2).
pap authdirect, device, and mediated sign-in, and disconnect (sign out, §4.4–§4.9).
pap apiopenapi calls with DPoP, and mcp tools with a Bearer for the MCP url only (§4.3, §6).
pap conversationsstart, send, events, follow (SSE), handoff, direct, close (§7).
pap operationsshow, approve, confirm, cancel (Operations v1).
pap requirementslist and show the normative requirement ledger.
pap conformancerun the conformance suite (--demo, --tls) and report JSON or JUnit.
pap commandslist and run the live-session actions by ID, as Studio and a REPL do.
pap exit-codesList every exit code.

Every protocol command is a thin adapter over the public SDK and prints either friendly text or versioned JSON (--json, schema_version: 1).

Protected input and local state​

A token, code, password, private key, or callback URL is never a command-line option. It is typed at a hidden prompt, or read one value per line from stdin (--stdin) or a file descriptor (--input-fd N). With --no-input, a command that needs input stops with exit code 4 instead of asking.

pap config set client_id https://agent.example/agent.json
pap agent key import --stdin < agent-signing-key.pem # printed: key:key-1 and its thumbprint
pap sessions start example.com --json
pap auth device ses_… --scope poppy:read # the user signs in on any device
pap auth mediated ses_… --stdin < credentials.txt # one field per line, then the code
pap api openapi ses_… GET /orders
pap api mcp ses_… search_products --arguments '{"q": "jacket"}'

The CLI's personal agent is a Reactor host. Its defaults live under $XDG_STATE_HOME/pap (or ~/.local/state/pap), owner-only and written atomically: the token store (secrets.json, pap.tokenStore), the signing and DPoP keys (keys.json), pairwise User IDs (pairwise.sqlite3), and the non-secret Session records and approvals (state.json). Plugins installed under the personal_agent_protocol.plugins entry-point group are discovered after these defaults and can replace any of them, for example with a system keyring. Only opaque references (session:…, account:…, key:…) are printed.

Operations: approve, then confirm​

pap operations approve ses_… exc_5Rt2 --revision 1 # shows the company and summary;
# the user types the operation ID
pap operations confirm ses_… exc_5Rt2 --revision 1

Approval is taken at the CLI's own prompt for one exact operation ID and revision; no message, option, or piped text counts as approval. confirm sends only a revision the user approved, and stops with exit code 6 when the revision or terms changed.

Exit codes​

CodeMeaning
0Done.
1The company refused, or the protocol failed (its error code is printed).
2Usage error: an unknown command, option, or value.
3Not configured: no signing key, no client_id or domain, or an unknown Session.
4Input required, but --no-input was given or no terminal is attached.
5Still pending: the user has not finished signing in before --timeout.
6Not approved: no approval for this exact operation ID and revision, or the terms changed; or a live-session action the host's approval surface did not approve.
130Interrupted.

Conformance​

pap conformance run --demo --tls --format junit --output pap.xml
pap conformance run --demo --suite operations --format json --output report.json
pap conformance report report.json --format junit
pap requirements show OPS-6-02

--demo runs the personal-agent suite against the in-process Manzanita reference company. Against your own company, use personal_agent_protocol.conformance with a UserDriver and Scenario for it.

Configure a profile​

pap config set domain example.com --profile work
pap config set timeout 5 --profile work
pap config show --profile work
pap config show --profile work --json
pap company verify --profile work --json

The accepted keys are domain, client_id, issuer, output, store_provider, tenant, and timeout. Configuration is stored at $XDG_CONFIG_HOME/pap/config.json, or ~/.config/pap/config.json when XDG_CONFIG_HOME is unset. Writes are atomic and the file is created with mode 0600.

Set PAP_PROFILE to select a default profile. Each supported key also has an environment override, such as PAP_DOMAIN, PAP_CLIENT_ID, and PAP_TIMEOUT. Environment values take precedence over profile values. pap config show prints each effective value's source; its JSON representation is versioned with schema_version: 1.

tenant (or PAP_TENANT; pap-cli by default) keeps applications apart in one state directory: pairwise User IDs and DPoP keys are per tenant, and every command sees only its tenant's Sessions. pap sessions show lists them, and another tenant's Session ID is answered like an unknown one (exit code 3), for every command. A tenant is 1 to 64 letters, digits, ., _ or -.

The configuration store deliberately rejects unknown keys. Do not put Account Tokens, passwords, authorization codes, private keys, assertions, DPoP proofs, or mediated secrets in this file or in command arguments. Commands that need secrets resolve Reactor contributions and expose only opaque references.

Inspect a company​

pap company discover example.com
pap company verify example.com --json
pap extensions list example.com --json
pap extensions check operations --version 1 example.com

Both company commands use the SDK's safe discovery client. verify therefore checks the requested organization domain, OAuth issuer relationship, redirect policy, response bound, and public-address preflight; it is not only a JSON syntax check. JSON output contains no terminal decoration and currently uses schema version 1.

Inspect a personal agent​

pap agent metadata https://agent.example/agent.json
pap agent jwks https://agent.example/agent.json --json

# Or use a non-secret profile default.
pap config set client_id https://agent.example/agent.json --profile work
pap agent metadata --profile work --json

metadata does not follow redirects and requires the document's client_id to equal the exact URL fetched. It validates HTTPS and same-domain JWKS and redirect URIs. jwks first repeats that metadata verification, then retrieves the key set through the same Reactor transport and URL-safety policy. JWKS redirects are bounded and cannot leave the client ID host. Only public P-256 ES256 and RSA RS256 signing keys are accepted; private members and symmetric keys are rejected. Both responses are public, schema-versioned inspection output and never include assertions or private key material.

Why this is a Reactor extension​

personal_agent_protocol.cli only creates the host and calls reactor.cli.extend(app). The distribution advertises its plugin using the standard entry-point group:

[project.entry-points."datalayer.reactor.cli"]
pap = "personal_agent_protocol.cli_plugin:plugin"

PapCliPlugin.provide_cli contributes the config, company, agent, extensions, and protocol Typer applications. A broken third-party CLI extension is warned about and skipped by Reactor. Installed companion distributions can add their own groups without importing PAP CLI internals or changing the pap host:

[project.entry-points."datalayer.reactor.cli"]
acme_pap = "acme_pap.cli:plugin"
import typer
from reactor import PluginManifest


class AcmePapCli:
def provide_cli(self, cli: typer.Typer) -> None:
acme = typer.Typer(name="acme", help="Acme PAP tools.")

@acme.command("inspect")
def inspect() -> None:
typer.echo("Acme inspection")

cli.add_typer(acme)


def plugin() -> tuple[PluginManifest, AcmePapCli]:
manifest = PluginManifest(name="acme.pap.cli", version="1.0.0")
return manifest, AcmePapCli()

Use provide_cli for command groups resolved when a command-line process starts. Use provide_slash_commands for actions that run inside a live Studio or REPL session: the PAP plugin registers its live-session actions there. Reactor owns their enable, disable, collision, and unregister lifecycle, so a command disappears when its plugin does.

Keep extension command IDs globally namespaced, and call public SDK services from command handlers. Do not couple an extension to personal_agent_protocol.cli_plugin implementation details.

Live-session actions​

The PAP plugin registers a set of actions, by namespaced ID, in whichever registry a host passes to provide_slash_commands: Reactor's command registry (Studio's palette, and pap commands run), or a REPL's SlashCommandRegistry. Every surface calls one implementation — the same coroutine as the pap command of the same name — and gets the same versioned JSON as that command's --json.

Actionpap commandArgumentsRuns
pap.company.discovercompany discoverdomainat once
pap.sessions.showsessions show[session_id]at once
pap.conversations.eventsconversations eventssession_id conversation_id [cursor]at once
pap.conversations.followconversations followsession_id conversation_id [last_event_id] [max_events]at once (at most 500 events)
pap.operations.showoperations showsession_id operation_idat once
pap.requirements.listrequirements list[role] [level] [status] [source]at once
pap.requirements.showrequirements showrequirement_idat once
pap.operations.canceloperations cancelsession_id operation_idafter the host's approval surface says yes
pap.sessions.endsessions endsession_idafter the host's approval surface says yes
pap.auth.disconnectauth disconnectsession_idafter the host's approval surface says yes
pap commands list
pap commands run pap.sessions.show
pap commands run pap.operations.show ses_… exc_5Rt2 # by position
pap commands run pap.requirements.list level='MUST NOT' role=company
pap commands run pap.sessions.end ses_… # exit code 6: approval_required

Arguments are text: by position, as name=value, as a JSON object (a REPL line or Studio), or as a mapping (Studio). An unknown name, a missing required argument, or an unknown action is a usage error (exit code 2). An action runs in the CLI's personal agent for the configured tenant, sees only that tenant's Sessions, and never asks for input.

What is not an action stays CLI-only: configuration (pap config), raw credential entry (pap agent key import, pap auth direct|device|mediated), starting and renewing Sessions, sending messages, calling APIs, and approving or confirming operations, which keep their own exact-ID-and-revision prompt.

The approval surface​

The three actions that change or end what the user holds run only where the host contributes an approval surface to the personal agent's Reactor (pap.approvalSurface, for Python hosts). It receives an ApprovalRequest — the action's ID and name, a plain-words summary, its arguments (IDs, never a credential), and the tenant — before anything is done, and only True approves:

RefusalWhenExit code
approval_requiredNo approval surface is contributed: use the pap command itself.6
not_approvedThe surface answered anything but True.6
approval_failedThe surface raised; its error is not repeated.6
approval_surface_ambiguousTwo surfaces at the winning order.6

A surface must ask the user on the host's own trusted UI: never a model, never a message in a conversation. Studio, for example, contributes its confirmation dialog and runs actions from its palette:

import asyncio

from reactor import PluginManifest, PluginPlatform
from reactor.slash import SlashCommandRegistry

from personal_agent_protocol import APPROVAL_SURFACES, ApprovalRequest
from personal_agent_protocol.cli_host import local_host
from personal_agent_protocol.cli_plugin import MANIFEST, PapCliPlugin


class StudioApproval:
"""Studio's own confirmation dialog: the user, not a model, answers."""

async def approve(self, request: ApprovalRequest) -> bool:
return await studio.confirm(title=request.action_name, text=request.summary)

def provide_contributions(self, contributions) -> None:
contributions.contribute(APPROVAL_SURFACES, self, contribution_id="studio", order=50)


host = local_host(plugins=[(PluginManifest(name="studio-approval", version="1"), StudioApproval())])
pap = PapCliPlugin(host=host)
platform = PluginPlatform()
platform.register_plugin(MANIFEST, pap) # the palette: Reactor's command registry

repl = SlashCommandRegistry()
pap.provide_slash_commands(repl) # a REPL: /pap.sessions.show, /pap.sessions.end …


async def main() -> None:
shown = await platform.execute_command("pap.sessions.show")
print(shown["sessions"])


asyncio.run(main())