Skip to content
Portal Control Protocol

Core Messages

PCP uses a framed stream protocol over Unix domain sockets. Each frame starts with a type tag (u8, 0..255) followed by a length-prefixed payload. On the client socket, payloads serialize as JSON. On the per-app sockets, payloads use a compact binary encoding.

The daemon exposes one client-facing socket and one per-app socket for each Tier 1 application. These are separate transport paths with different message sets, so there is no ambiguity about which encoding applies.

The protocol defines 27 StreamMessageType variants. Each has a numeric tag, a direction, a liveness status, and a payload type.

# Variant Direction Live? Payload
1 DetectRequest client to daemon yes DetectRequestPayload { app_id }
2 DetectResponse daemon to client yes DetectResponsePayload { result: DetectionResult }
3 ExecuteRequest client to daemon yes ExecuteRequestPayload { capability, target?, params, admin }
4 ExecuteResponse daemon to client yes ExecuteResponsePayload { result: ExecutionResult }
5 EventNotification daemon to client yes PushEventPayload (alias of #16)
6 RegistryQuery client to daemon yes RegistryQueryPayload { kind }
7 RegistryResponse daemon to client yes RegistryResponsePayload { kind }
8 Ping either direction yes PingPayload (empty)
9 Pong daemon to client yes PongPayload { capability_count, app_count, subscription_count, journal_length, uptime_secs }
10 RegisterCapability reserved no RegisterCapabilityPayload
11 UnregisterCapability reserved no UnregisterCapabilityPayload
12 ExecuteCapability reserved no alias of #3
13 QueryCapabilities reserved no QueryCapabilitiesPayload { filter }
14 SubscribeEvents client to daemon yes SubscribeEventsPayload { filter }
15 UnsubscribeEvents client to daemon yes UnsubscribeEventsPayload { subscription_id }
16 PushEvent daemon to client yes PushEventPayload { event, sequence }
17 ErrorResponse daemon to client yes ErrorResponsePayload { code: u16, message }
18 CapabilityResponse reserved no CapabilityResponsePayload { capability? }
19 InvokeCapability daemon to app yes binary payload (Tier 1 path)
20 CapabilityResultMsg app to daemon yes binary payload
21 GetManifest daemon to app yes binary payload
22 ManifestResponse app to daemon yes binary payload
23 ValidateParamsMsg daemon to app yes binary payload
24 ValidateResultMsg app to daemon yes binary payload
25 QueryState daemon to app yes binary payload
26 StateResponse app to daemon yes binary payload
27 AppShutdown daemon to app yes binary payload (no response expected)

“Reserved” variants exist in the type enum for forward compatibility but are not handled by the current daemon. Sending them on the client socket yields an ErrorResponse with code 400 (“unsupported message type”).

Messages tagged client to daemon travel on the client socket. Messages tagged daemon to client are replies or push events on that same socket. Messages tagged daemon to app and app to daemon travel on per-app sockets, which use binary encoding rather than JSON.

The daemon’s IPC handler accepts exactly six request types on the client socket:

  1. Ping (#8) – liveness check, no side effects
  2. RegistryQuery (#6) – read registry state
  3. DetectRequest (#1) – trigger adapter-based app detection
  4. ExecuteRequest (#3) – invoke a capability
  5. SubscribeEvents (#14) – begin event subscription
  6. UnsubscribeEvents (#15) – cancel event subscription

Any other message type on the client socket produces ErrorResponse 400.

When the daemon receives an ExecuteRequest (tag 3), it runs the four-step dispatch pipeline:

ExecuteRequest
|
1. In-process ownership?
| registry lookup to find owner app_id
| if registered in InProcessDispatcher, invoke directly
| (runtime service capabilities: learning, recovery, inspection, coordination)
|
2. Domain == "system" or "compositor"?
| delegate to PlatformAdapter
|
3. App-domain capability:
| a. If Tier 1 socket exists for owner app, invoke via per-app socket
| b. Else use AT-SPI2 path:
| - accessible_path missing? run element auto-resolution
| - registered caps: try adapters in priority order, first success wins
| - unregistered caps: run detect() to find owner, then execute once
|
4. Timeouts and errors:
30s timeout -> ErrorResponse 504
adapter failure -> ErrorResponse 500
unsupported -> ErrorResponse 400
rate-limited -> ErrorResponse 429

Every execution is audited on completion. The audit entry carries a chain hash for tamper detection.

A RegistryQuery (tag 6) carries a RegistryQueryKind that tells the daemon what to look up. There are six variants:

Kind Fields Description
Capabilities filter: Option<RegistryFilter> List capabilities matching a filter. Omit the filter for all.
CapabilityById id: String Look up a single capability by its ID.
Apps (none) List all registered applications with their descriptors.
EventHistory limit: Option<usize> Retrieve recent events from the event journal.
Health (none) Health check. Returns a PongPayload equivalent.
ElementTree app_id: String Discover the semantic element tree for an application.

The daemon replies with a RegistryResponseKind that matches the query:

Kind Fields Matches Query
Capabilities caps: Vec<CapabilityDescriptor> Capabilities, CapabilityById
Apps apps: Vec<AppDescriptor> Apps
Events events: Vec<CapabilityEvent> EventHistory
Health health: PongPayload Health
Elements elements: Vec<ElementInfo> ElementTree

The ElementTree query returns ElementInfo structs rather than full Element objects. This is a flat, wire-friendly view of the accessibility tree:

pub struct ElementInfo {
pub accessible_path: String,
pub bus_name: String,
pub role: String,
pub name: Option<String>,
pub actions: Vec<String>,
pub states: Vec<String>,
pub text: Option<String>,
}

Each ElementInfo represents one node in the AT-SPI2 accessibility tree. The accessible_path is the D-Bus object path for the node. The daemon’s element auto-resolution splits this path when mapping an ElementId back to a concrete node.

Ping (tag 8) can be sent by either side. The daemon replies with Pong (tag 9), which carries diagnostic counters:

{
"capability_count": 87,
"app_count": 12,
"subscription_count": 3,
"journal_length": 1042,
"uptime_secs": 3624
}

When a Tier 1 app receives a Ping on its per-app socket, it replies with a zeroed Pong.

SubscribeEvents (tag 14) takes a filter and returns an acknowledgement carrying a subscription_id. After that, the daemon pushes PushEvent (tag 16) frames whenever matching events occur. Each push carries the event data and a monotonically increasing sequence number.

UnsubscribeEvents (tag 15) takes a subscription_id and cancels that subscription. No further push events are delivered for it.

The daemon sends ErrorResponse (tag 17) for any failure. The payload carries a numeric code and a human-readable message:

Code Meaning
400 Unsupported message type
404 Capability or app not found
429 Rate-limited, slow down
500 Adapter failure
504 Execution timed out (30s default)

The ErrorResponse is the universal failure envelope. There is no separate JSON-RPC error layer; the tag-based framing replaces the JSON-RPC method dispatch model entirely.

Tier 1 applications serve the PcpServer trait on their per-app sockets. The daemon sends these messages, and the app replies:

# Message Handler Reply
21 GetManifest PcpServer::manifest() 22: ManifestResponse
19 InvokeCapability PcpServer::invoke() 20: CapabilityResultMsg
23 ValidateParamsMsg PcpServer::validate_params() 24: ValidateResultMsg
25 QueryState PcpServer::query_dynamic_state() 26: StateResponse
27 AppShutdown PcpServer::shutdown() (none, connection closes)

These six messages form the entire Tier 1 contract. Errors within the per-app protocol are carried inside CapabilityResultMsg rather than as separate error frames.

Path Encoding Used for
Client socket JSON Ping, RegistryQuery, Detect, Execute, Subscribe, Unsubscribe, and all daemon replies
Per-app sockets binary GetManifest, Invoke, ValidateParams, QueryState, Shutdown

Both paths carry the same StreamMessageType tags. The difference is payload encoding only.

Last updated: