Skip to content
Portal Control Protocol

Quickstart

This quickstart walks through building a Tier 1 native application that exposes PCP capabilities to the System Intelligence layer. You’ll create a simple text editor that registers two capabilities: editor.read to retrieve the current document content, and editor.save to write new content back. By the end, the daemon will discover your app at startup, index its capabilities, and make them available to SI for invocation.

Prerequisites:

  • A working Rust toolchain (edition 2021)
  • A running PCP daemon
  • The pcp-core and pcp-ipc crates, available from the PCP workspace

No registration RPC and no manual handshake. Your application serves its capabilities on its own PCP socket, and the daemon discovers it through its desktop entry and signed manifest.

A Tier 1 application implements the PcpServer trait from pcp-ipc. This trait tells the daemon what your app can do and how to invoke each operation. The server receives capability IDs, parameter payloads, and an invocation context, then returns structured results or typed errors.

Here is the server for the text editor:

use pcp_core::prelude::*;
/// A Tier 1 text editor exposing semantic capabilities.
pub struct EditorServer {
manifest: CapabilityManifest,
}
#[async_trait]
impl PcpServer for EditorServer {
async fn manifest(&self) -> CapabilityManifest {
self.manifest.clone()
}
async fn invoke(
&self,
capability_id: &str,
_ctx: &InvocationContext,
params: serde_json::Value,
) -> Result<CapabilityResult, PcpError> {
match capability_id {
"editor.read" => {
let text = self.get_document_text()?;
Ok(CapabilityResult {
success: true,
data: Some(serde_json::json!({ "text": text })),
state_after: None,
undo_token: None,
})
}
"editor.save" => {
let content = params["text"]
.as_str()
.ok_or(PcpError::InvalidParams(
"missing 'text'".into(),
))?;
self.save_document(content).await?;
Ok(CapabilityResult::success())
}
_ => Err(PcpError::NotFound(capability_id.into())),
}
}
async fn validate_params(
&self,
capability_id: &str,
params: &serde_json::Value,
) -> Result<(), PcpError> {
if capability_id == "editor.save" && params["text"].as_str().is_none() {
return Err(PcpError::InvalidParams("missing 'text'".into()));
}
Ok(())
}
async fn subscribe_events(&self, _event_types: Vec<String>) -> Result<(), PcpError> {
Ok(())
}
async fn query_dynamic_state(&self, _capability_id: &str) -> Result<serde_json::Value, PcpError> {
Ok(serde_json::json!({ "dirty": self.is_dirty() }))
}
async fn shutdown(&self) {
self.flush().await;
}
}

A few things to note about this implementation:

  • The invoke method dispatches on the capability ID string. The daemon routes each invocation to this method over your app’s socket, with the ID, a JSON parameter payload, and an InvocationContext that carries session and auth metadata.
  • editor.read is a read-only operation. It returns the document text in the data field and sets success to true. No undo token is needed because the operation has no side effects.
  • editor.save extracts the text parameter from the payload, validates its presence, and persists it. The InvalidParams error variant makes missing or malformed inputs explicit to the caller.
  • validate_params mirrors the manifest schemas. The daemon calls it before invoke so a malformed payload never reaches your execution path.
  • Any unrecognized capability ID returns PcpError::NotFound. The daemon uses this to distinguish between an unsupported operation and a transient failure.

The get_document_text, save_document, and flush methods are application-level logic. They are not part of the PCP contract. The protocol only cares about the trait boundary: what goes in, what comes out, and whether it succeeded.

To serve the implementation, wrap it in a PcpServerRunner from pcp-ipc. The runner listens on your app’s socket, decodes incoming frames, and dispatches to these trait methods.

Every Tier 1 application ships a CapabilityManifest alongside its binary. This JSON document declares the app’s identity, version, and the full set of capabilities it exposes. The daemon reads this manifest during discovery to build its capability index without needing to instantiate the application.

Here is the manifest for the text editor:

{
"app_id": "com.example.editor",
"version": "1.0.0",
"manifest_version": "1.0",
"capabilities": [
{
"id": "editor.read",
"name": "Read Document",
"description": "Read the current document content",
"category": "TEXT_QUERY",
"parameters": {
"type": "object",
"properties": {}
},
"returns": {
"type": "object",
"properties": {
"text": { "type": "string" }
}
},
"side_effects": "read",
"auth_level": "user"
},
{
"id": "editor.save",
"name": "Save Document",
"description": "Save the document with provided content",
"category": "TEXT_INPUT",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "New document content"
}
},
"required": ["text"]
},
"returns": {
"type": "object",
"properties": {}
},
"side_effects": "write",
"auth_level": "user"
}
]
}

The manifest must be Ed25519-signed. The signature proves that the capabilities declared in the JSON actually belong to the app that ships it. Without a valid signature, the daemon rejects the manifest at registration. Place the signed manifest at the well-known path for your app (conventionally alongside the binary or in a platform-standard manifest directory).

Each capability entry declares its side_effects level (read or write), its auth_level (who is allowed to invoke it), and its parameter and return schemas. The daemon uses these schemas to validate invocations before they reach your server. A call to editor.save that omits the text parameter never touches your code; the daemon catches the schema violation and returns an error to the caller.

Tier 1 applications do not register themselves manually. The daemon scans desktop entries and well-known manifest locations at startup. When it finds your com.example.editor manifest, it:

  1. Verifies the Ed25519 signature against the trusted key set.
  2. Probes the binary for sanity.
  3. Parses the manifest and indexes each capability by ID, category, and app origin.
  4. Connects to your application’s PCP socket as a client.

From that point on, SI can invoke editor.read or editor.save by capability ID, and the daemon routes the call over your app’s socket to your PcpServer implementation. Framing, connection management, and reconnection are handled by the protocol libraries on both sides. Discovery and binding happen as a consequence of the manifest existing in the right place with a valid signature and your app listening on its socket.

If the manifest is malformed, the signature fails, or a capability ID collides with an already-registered capability, the daemon logs the failure and skips that application. It does not crash, and it does not block other applications from registering.

Tier 1 integration comes with rules that distinguish it from lower tiers:

  • Operate on the data model, not the rendered surface. Tier 1 capabilities read and write application state directly. They do not query pixel positions, simulate mouse clicks, or read the framebuffer. The point of Tier 1 is semantic access: invocations operate on your data structures, not your UI layer.
  • No script injection. The evaluate_script mechanism is forbidden in Tier 1 handlers. If you need to execute logic, do it in Rust, not by injecting JavaScript or shell commands into the application’s rendering context.
  • Manifest integrity is enforced. The compositor verifies the Ed25519 signature on every manifest at registration time. A tampered manifest is rejected. A missing signature is rejected. There is no downgrade path to an unsigned manifest.
  • Capability IDs are global. Once registered, a capability like editor.read is addressable by any part of the SI layer. Choose IDs that are specific enough to avoid collisions across applications. The app_id namespace in the manifest helps, but capability IDs themselves must be unique within the daemon’s registry.

Last updated: