Build with pap://
pap:// is a delegation protocol you can hold in your hands: sign a mandate that says what an agent may do, run a handshake that proves both sides agreed, and confine the work to what was authorized. This is the working reference — every type and call below is in the source.
Workspace version 0.8.3. Open source, MIT-licensed.
Install
pap:// ships four SDKs from one source tree. Rust is the reference implementation; Python and TypeScript are bindings over it; WASM runs the core in the browser. Pick your language.
The Rust crates are the reference implementation. Clone the repo and run the test suite to confirm your toolchain, then depend on the crates you need.
git clone https://gitlab.baursoftware.com/baur-software/pap.git
cd pap
cargo test
# run a worked example end to end
just run-example pap-delegation-chain-example
From prompt to mandate
The part people don't believe is that you can type a sentence and end up with delegated authority. Here is the honest answer: the sentence never becomes authority on its own. It gets classified into a named action, and then a human signs a mandate for that action. Two separate steps — and the second one is a person's key, not a model's guess.
Classification runs in three levels, cheapest first. A bare URL is a deterministic shortcut — it routes straight to a read action with no scoring at all. Anything else is ranked with BM25, the same term-frequency method a search engine uses, against the catalog of available agents: "book a hotel in Paris" scores against a lodging agent and resolves to a reserve action. Only when BM25 has no confident match does the prompt go to an NLU classifier for a considered read. Each level is inspectable; none of them is a black box that mints power.
Whatever the pipeline resolves to is just a proposal. It becomes a single ScopeAction, wrapped in a Scope, and the principal signs it. The signature is the grant. If the classifier misreads the prompt, the worst case is a mandate scoped to the wrong action — which the receiver can still reject, and which a delegation can only narrow, never widen. The prompt is evidence of intent; the human's key is what turns intent into authority.
use pap_core::mandate::Mandate;
use pap_core::scope::{DisclosureSet, Scope, ScopeAction};
use pap_did::PrincipalKeypair;
// 1. Classify the prompt into a schema.org action. Cheapest level first:
// • bare URL → "schema:ReadAction" (deterministic)
// • BM25 catalog match → e.g. "schema:ReserveAction" (term-frequency rank)
// • no confident match → NLU classifier ("schema:AnalyzeAction")
let prompt = "book a hotel in Paris";
let (resolved_action, _preferred_agent, _query) = route_intent(prompt, &catalog);
// resolved_action == "schema:ReserveAction"
// 2. The resolved action is a PROPOSAL. It becomes one ScopeAction, and the
// human principal signs the mandate. The signature is the grant of authority —
// the scope is derived from classification, never hardcoded.
let principal = PrincipalKeypair::generate();
let mut mandate = Mandate::issue_root(
principal.did(),
orchestrator.did(),
Scope::new(vec![ScopeAction::new(&resolved_action)]),
DisclosureSet::empty(),
ttl,
);
mandate.sign(principal.signing_key()).unwrap();
// A misread prompt can only produce a narrower, rejectable mandate —
// delegation can never widen scope past what was signed.
The resolved action becomes the mandate scope, then the principal signs it — adapted from examples/intent-routing. The scope is derived from classification, never hardcoded.
The prompt proposes. The signature grants. A misclassified prompt cannot widen scope past what was signed — it can only produce a narrower, rejectable mandate.
Mandates & scope
A mandate is the signed grant of authority: who is delegating, to whom, for what actions, carrying what disclosures, and until when. The principal issues a root mandate and signs it. Any holder can delegate a narrower one to a sub-agent.
Delegation cannot widen. The child scope must be a subset of the parent, and the child TTL cannot outlast the parent. Both are checked when you call delegate, not merely trusted: an over-broad scope returns DelegationExceedsScope, and a longer TTL returns DelegationExceedsTtl.
A Scope is a set of ScopeActions. Scope::permits answers "is this action allowed?"; Scope::contains answers "is this whole scope a subset of that one?" — the same subset check delegation enforces. MandateChain::verify_chain walks the full hierarchy, confirming every signature, every parent link, and scope containment at each level.
use chrono::{Duration, Utc};
use pap_core::error::PapError;
use pap_core::mandate::{Mandate, MandateChain};
use pap_core::scope::{DisclosureSet, Scope, ScopeAction};
use pap_did::PrincipalKeypair;
// The human principal is the root of trust.
let principal = PrincipalKeypair::generate();
let orchestrator = PrincipalKeypair::generate();
// A root mandate: broad scope, 4-hour TTL.
let root_scope = Scope::new(vec![
ScopeAction::new("schema:SearchAction"),
ScopeAction::with_object("schema:ReserveAction", "schema:Flight"),
]);
let mut root = Mandate::issue_root(
principal.did(),
orchestrator.did(),
root_scope,
DisclosureSet::empty(),
Utc::now() + Duration::hours(4),
);
root.sign(principal.signing_key()).unwrap();
// Delegate a strict subset to a sub-agent. The child scope must be a
// subset of the parent, and its TTL cannot outlast it.
let planner = PrincipalKeypair::generate();
let planner_scope = Scope::new(vec![ScopeAction::new("schema:SearchAction")]);
let mut child = root
.delegate(planner.did(), planner_scope, DisclosureSet::empty(),
Utc::now() + Duration::hours(3))
.unwrap();
child.sign(orchestrator.signing_key()).unwrap();
// Widening is refused, not trusted.
let over_broad = Scope::new(vec![ScopeAction::new("schema:PayAction")]);
match child.delegate(planner.did(), over_broad, DisclosureSet::empty(),
Utc::now() + Duration::hours(2)) {
Err(PapError::DelegationExceedsScope) => { /* rejected */ }
_ => unreachable!(),
}
// Verify the whole chain: signatures, parent links, scope containment.
let chain = MandateChain { mandates: vec![root, child] };
chain.verify_chain(&[principal.verifying_key(), orchestrator.verifying_key()]).unwrap();
A four-level delegation chain from examples/delegation-chain. Scope narrows and TTL shrinks at every hop; the chain verifies end to end.
Method names differ by language: Rust uses issue_root / verify; Python uses issue_root / verify_with_keypair with DisclosureSet.empty(); TypeScript uses Mandate.issueRoot with camelCase throughout.
The six-phase handshake
A transaction between two agents is a six-phase handshake, driven by AgentClient against the receiver's AgentServer. Use run_full_handshake for standard sessions: it sequences the phases in the required order and makes Phase 5 — the co-signed receipt — mandatory, so a session cannot silently skip its accountability record.
The mandate TTL is checked before the network I/O of every phase, and any protocol error closes the session before returning. The low-level phase methods — present_token, exchange_did, send_disclosures, request_execution, exchange_receipt, close_session — are there for streaming or custom recovery, but they make it easy to omit Phase 5, so prefer the high-level call.
On the receiving side, implement the AgentHandler trait and serve it with AgentServer. The animated walk-through of all six phases lives on the protocol page. See the animated handshake →
use pap_transport::client::AgentClient;
let client = AgentClient::new("https://receiver.example");
// The recommended high-level call: it sequences all six phases in order
// and makes Phase 5 (the co-signed receipt) mandatory.
let (receipt, result) = client
.run_full_handshake(
token, // signed CapabilityToken authorising this interaction
initiator_session_did, // ephemeral: pap_did::SessionKeypair::generate()
vec![], // SD-JWT disclosures — empty for a zero-disclosure session
pre_signed_receipt, // TransactionReceipt half-signed by the initiator
mandate_expires_at, // TTL, re-checked before every phase
)
.await?;
// `receipt` is co-signed by both parties; `result` is the Schema.org JSON-LD output.
run_full_handshake sequences all six phases and returns the co-signed receipt with the Schema.org result.
Envelopes & messages
Every protocol message travels inside an Envelope that binds it to a session, a sender, a recipient, and a sequence number. After the DID-exchange phase, each envelope carries an Ed25519 signature from the sender's session key.
The signature covers the canonical signable bytes — SHA-256 of session_id, sequence, and payload — so tampering with any of the three invalidates it. Change the payload, change the sequence, or sign with the wrong key, and verify rejects it. That is how replay and forgery are caught at the transport layer.
use pap_did::SessionKeypair;
use pap_proto::{Envelope, ProtocolMessage};
let initiator = SessionKeypair::generate();
let mut env = Envelope::new(
"session-demo-001",
initiator.did(),
receiver.did(),
1, // sequence number — replay protection
ProtocolMessage::DisclosureOffer { disclosures: vec![] },
);
// Sign over the canonical bytes: SHA-256(session_id || sequence || payload).
env.sign(initiator.signing_key());
env.verify(&initiator.verifying_key()).unwrap(); // VALID
// Any tampering invalidates the signature.
let mut tampered = env.clone();
tampered.sequence = 999;
assert!(tampered.verify(&initiator.verifying_key()).is_err()); // REJECTED
Signing an envelope and detecting tampering, from examples/protocol-envelope.
Sandboxed execution
Wrap any AgentHandler in SandboxedHandlerWrapper to run the approved action under OS-level confinement. A CapabilityPolicy sets the limits — execution timeout, and whether network, filesystem, and subprocess access are allowed. Everything defaults to denied.
What actually enforces those limits depends on where you run. The receipt never claims a protection that didn't run: the AttestationReceipt records an EnforcementBackend, and its CapabilityProof reports what was actually applied, never what the policy requested.
use std::sync::Arc;
use pap_sandbox::{CapabilityPolicy, SandboxedHandlerWrapper, new_spawner};
// Everything is denied by default. Grant only what the action needs.
let policy = CapabilityPolicy {
execution_timeout_secs: 30,
network_allowed: false,
filesystem_allowed: false,
subprocess_allowed: false,
..Default::default()
};
let wrapped = SandboxedHandlerWrapper::new(
inner_handler, // Arc<dyn AgentHandler>
new_spawner(), // picks the enforcement backend for this OS
policy,
true, // sandbox_enabled
agent_did,
agent_name,
"schema:SearchAction", // action_type
);
// After a run, the co-signed AttestationReceipt reports the EnforcementBackend
// that actually ran and, in its CapabilityProof, exactly what was applied —
// never what the policy merely requested.
LinuxSeccomp
A seccomp-BPF filter is loaded on the executing thread before the agent runs. It denies network and subprocess syscalls at the kernel — those blocks are real. seccomp filters on syscall number, not pointers, so it cannot restrict by file path; the proof never claims filesystem_restricted via seccomp.
Docker
The action runs in a sibling container with capabilities dropped, no network, a read-only rootfs, and a memory cap. The container runtime enforces those at the kernel, so this backend is the one that genuinely restricts the filesystem.
NoneNative
macOS, BSD, and Windows have no syscall enforcement wired yet: you get a separate process and a wall-clock timeout, and every syscall-restriction flag on the proof is false. Run under Docker (or a Linux host with seccomp) for kernel-enforced isolation.
Examples & further reading
The repository ships runnable examples for every part of the protocol. Clone it, then run one with just — for instance the delegation chain or the protocol envelope shown above.
delegation-chain
A four-level mandate hierarchy: scope narrowing, TTL shrinking, chain verification, decay-state transitions, and rejected over-broad delegations.
protocol-envelope
Envelope signing, tamper detection, wrong-key rejection, and sequence-number replay prevention.
selective-disclosure-decay
SD-JWT selective disclosure paired with mandate decay — releasing only the fields a task needs, then degrading on non-renewal.
travel-booking
A realistic multi-agent booking flow: several scoped agents transacting across services without sharing the full itinerary.
Deeper reading: the full protocol specification.
A word on LangChain, CrewAI, and MCP
There is an integration guide that shows pap:// bolted onto these frameworks, and its code is accurate. We are keeping it honest by telling you what we found when we tried: these stacks can't actually behave with pap://, and the reason is architectural, not a missing feature.
They were built around one shared context that every agent in the graph can see, with a hosted model provider as the root of trust. pap:// exists to remove exactly those two things. A mandate check placed in front of a framework call can refuse the call, but it cannot un-share the context the framework already handed to the model, and it cannot confine execution the framework runs in-process. You get a signature on the doorway and an open floor plan behind it — half a trust model, which is the thing pap:// was built to end.
So our guidance is to not wrap these frameworks and call it done. Build against pap:// directly — issue the mandate, run the handshake, sandbox the execution — so both boundaries are real. The integration guide is there if you have existing code to bridge during a migration, but treat it as a transition, not a destination.
Built by Baur Software. Licensed MIT.