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:
| Command | Purpose |
|---|---|
pap config | Show, get, set, unset, and list non-secret profiles and value provenance. |
pap company | Discover and verify a company's PAP document. |
pap agent | Verify personal-agent metadata and public JWKS; import the agent's signing key. |
pap extensions | List advertised PAP extensions and check one required major version. |
pap sessions | start, renew, show, and end Sessions (§4.2). |
pap auth | direct, device, and mediated sign-in, and disconnect (sign out, §4.4–§4.9). |
pap api | openapi calls with DPoP, and mcp tools with a Bearer for the MCP url only (§4.3, §6). |
pap conversations | start, send, events, follow (SSE), handoff, direct, close (§7). |
pap operations | show, approve, confirm, cancel (Operations v1). |
pap requirements | list and show the normative requirement ledger. |
pap conformance | run the conformance suite (--demo, --tls) and report JSON or JUnit. |
pap commands | list and run the live-session actions by ID, as Studio and a REPL do. |
pap exit-codes | List 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​
| Code | Meaning |
|---|---|
| 0 | Done. |
| 1 | The company refused, or the protocol failed (its error code is printed). |
| 2 | Usage error: an unknown command, option, or value. |
| 3 | Not configured: no signing key, no client_id or domain, or an unknown Session. |
| 4 | Input required, but --no-input was given or no terminal is attached. |
| 5 | Still pending: the user has not finished signing in before --timeout. |
| 6 | Not 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. |
| 130 | Interrupted. |
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.
| Action | pap command | Arguments | Runs |
|---|---|---|---|
pap.company.discover | company discover | domain | at once |
pap.sessions.show | sessions show | [session_id] | at once |
pap.conversations.events | conversations events | session_id conversation_id [cursor] | at once |
pap.conversations.follow | conversations follow | session_id conversation_id [last_event_id] [max_events] | at once (at most 500 events) |
pap.operations.show | operations show | session_id operation_id | at once |
pap.requirements.list | requirements list | [role] [level] [status] [source] | at once |
pap.requirements.show | requirements show | requirement_id | at once |
pap.operations.cancel | operations cancel | session_id operation_id | after the host's approval surface says yes |
pap.sessions.end | sessions end | session_id | after the host's approval surface says yes |
pap.auth.disconnect | auth disconnect | session_id | after 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:
| Refusal | When | Exit code |
|---|---|---|
approval_required | No approval surface is contributed: use the pap command itself. | 6 |
not_approved | The surface answered anything but True. | 6 |
approval_failed | The surface raised; its error is not repeated. | 6 |
approval_surface_ambiguous | Two 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())