IPC and remote control
Reference the local OpenPets IPC transports, discovery handshake, request protocol, lease model, remote control surface, and security boundaries.
IPC and remote control
Normal agent activity travels over a local IPC channel between the
agent-side code and the desktop app. The local wire contract is defined by
@open-pets/client (packages/client/) and served by local-ipc.ts. OpenPets
also has a separate, disabled-by-default remote-control protocol for explicitly
paired coding agents. This doc explains both contracts; their request routers,
authentication, discovery behavior, and capabilities must remain separate.
Source maps: packages/client/src/codemap.md (client),
apps/desktop/src/codemap.md (local server side: local-ipc*.ts,
lease-manager.ts; remote side: remote-control-*.ts).
Why local IPC and not HTTP
The pet app is a local companion. Commands are tiny, frequent, and must never leave the machine. A local socket gives low latency, no network exposure, and a natural place to enforce trust (a token + a private endpoint). The protocol is a line-delimited JSON request/response over a single connection per call.
Transports
The client and server pick a transport per platform:
- Unix domain socket - macOS and Linux.
- Windows named pipe - Windows.
- TCP (IPv4) - used for cross-platform/WSL: a WSL client connects to the Windows desktop app over a private IP.
TCP is the one that touches the network, so it is locked down (see Security).
Discovery handshake
The app writes a discovery file at a platform-specific path
(local-ipc-paths.ts on the server, discovery.ts on the client). The file
contains the endpoint to connect to and an auth token. A client:
- Reads and validates the discovery file (size, permissions, symlink checks;
on Linux,
XDG_RUNTIME_DIRmust be0o700and owned by the user). - Parses + validates the endpoint (
parseIpcEndpoint/validateEndpoint). - Opens a connection and sends a request carrying the token.
If the file is missing or the app is down, the client fails fast - integrations treat the app as “unavailable” and degrade gracefully rather than blocking the agent.
Protocol shape
Defined in protocol.ts (client) and local-ipc-protocol.ts (server):
- Protocol version
v1, validated on both ends. - A message is one JSON object terminated by
\n. Max message size 16KB. - Timeouts: ~2s to connect, ~3s for a response.
- Requests carry
{ id, version, token, method, params }. - Responses are a discriminated union on
ok:{ ok: true, ... }or{ ok: false, error, code }. The client raises a typedOpenPetsClientErrorwith an error code on failure.
The client factory createOpenPetsClient(options) exposes the high-level
methods; sendRequest() is the low-level escape hatch. Result parsers validate
shapes before returning.
Request surface
| Method | Purpose |
|---|---|
hello |
Handshake / liveness probe |
status |
App + pet status snapshot |
pets.list |
Installed pets |
pets.install |
Install a catalog pet through the running app |
pets.install-local |
Install a local pet from an absolute zip-file or folder path |
pet.react |
Set a pet reaction (animation state) |
pet.say |
Show a speech bubble on a pet |
pet.showMedia |
Show a local image inside a pet’s speech bubble |
lease.acquire / lease.heartbeat / lease.release |
Manage a pet lease |
Client method names (hello(), status(), listPets(), installPet(),
installLocalPet(), acquireLease(), heartbeatLease(), releaseLease(),
react(), say(), showMedia()) wrap these. installLocalPet() requires an
absolute path and an explicit zip/folder kind. react()/say()/
showMedia() accept an optional leaseId to target a specific pet.
Experimental multi-pet LAN mode converts only default-target working,
editing, running, and testing reactions into a coarse authenticated
work activity when the owner’s pet is away and meeting another pet. The LAN
request contains no MCP text or arbitrary message field; pet.say,
pet.showMedia, non-work reactions, and explicit lease targets remain local.
The LAN coordinator binds these mutations to a per-host session credential
issued during registration; possession of the shared LAN token alone cannot
publish activity or return a pet for another active host.
pet.showMedia renders a local image file as a transient media bubble on the
pet - for example an image a local generation tool just produced. Params:
path (required absolute path, extension must be .png/.jpg/.jpeg/
.webp/.gif, file capped at 10 MB), optional message (validated exactly
like pet.say), optional reaction, and optional durationMs (1000–30000,
default 8000). The image never leaves the machine: the app reads the validated
local file and renders it inside the bubble via a file: URL, sized to the
bubble’s media constraints.
pet.showMedia also accepts an optional clickUrl: clicking the media bubble
opens it via the shell on top of the normal dismiss behavior, so the sender
can hand the click back to itself (a custom registered app protocol) or to a
site (https:). Validation is deny-list based: local-content and script
schemes (file:, javascript:, data:, …), plain http:, and side-effect
Windows shell handlers are rejected; unregistered custom schemes are an OS
no-op.
The lease model
Leases are how multiple agents and the default pet coexist without fighting over
one window. The model (server side in lease-manager.ts):
- A lease is a short-lived claim with a 15s TTL, kept alive by heartbeats.
resolveTarget()decides whether a command hits the default pet or an explicit agent pet.- Re-acquiring is idempotent per client. When a client process re-acquires
while it still holds a live lease, the manager refreshes that existing lease
(same
leaseId, same target) instead of resolving a new target. This stops a transient heartbeat lapse from silently downgrading an explicit agent pet to the default pet on the next acquire. Client identity is the client PID plus a per-processsessionNonce(a random id minted once per client process), so a recycled PID belonging to a brand-new process is treated as a distinct session and gets its own pet rather than inheriting the previous session’s lease. On reuse the manager also re-validates that the held target is still eligible; if it is not (for example the pet was uninstalled or went broken), it releases the stale lease and resolves a fresh target instead of handing back an unavailable pet. - The first explicit lease for a pet triggers
showAgentPet(); the last explicit lease released triggerscloseAgentPetIfOpen(). So agent pets appear on demand and disappear when their agents are done. - Liveness reclaims dead sessions. A periodic check releases a lease once its owning process is gone, probing the terminal owner PID (when known) as well as the client PID - so a lease can’t outlive its session even when the client process is orphaned but still alive.
- The default pet is persistent and not lease-bound.
Integrations follow a consistent pattern: acquire a lease on first activity,
heartbeat on an interval (the MCP server uses ~5s; OpenCode renews with a ~2s
buffer before expiry), and release on shutdown. If a heartbeat fails, an
integration first stashes the stale leaseId and retries lease.heartbeat to
restore it before falling back to a fresh lease.acquire, so a dropped heartbeat
never re-routes an agent pet onto the default. The MCP server additionally
releases its lease and exits exactly once when its stdio transport closes (or
on SIGINT/SIGTERM), so the pet tears down promptly when the session ends and
the shutdown path never runs twice. Failures are swallowed so the agent is never
blocked by pet IPC.
See Pets for what happens once a command reaches a pet window, and Agent integrations for how each integration drives this surface.
Reaction validation
Reactions are a closed enum. The client validates a reaction against the allowed
set before sending, and @open-pets/agent-events validates speech strings
(single line, length-bounded, no code/URLs/paths/secrets) so nothing unsafe ever
reaches a bubble. See Agent integrations.
Remote control protocol
Remote control is an independent versioned line-delimited JSON protocol owned by
apps/desktop/src/remote-control-service.ts and
packages/client/src/remote-protocol.ts. It is not a network transport for
local IPC and it never reads or writes the local discovery file.
The service is disabled by default. Enabling it requires a local configuration
with a concrete IPv4 address from loopback, private, link-local, or CGNAT
100.64.0.0/10 ranges and a non-zero port. Wildcard addresses, public
addresses, hostnames, IPv6, and implicit/default bindings are rejected. The
client accepts remote configuration
only through explicit remote: { endpoint, token, clientId? } options, the
equivalent remoteEndpoint/remoteToken/remoteClientId options, or the
carefully named OPENPETS_REMOTE_ENDPOINT, OPENPETS_REMOTE_TOKEN, and
OPENPETS_REMOTE_CLIENT_ID environment variables. A configured remote client
never consults discovery.
Remote messages are capped at 4 KiB, one request per bounded socket, and are rate-limited per remote address. The absolute connection deadline remains active through response shutdown, so a peer that leaves the TCP connection half-open cannot retain a concurrent-socket slot indefinitely; complete responses remain readable before the bounded socket is reclaimed. Malformed, unauthenticated, oversized, and unsupported requests receive generic errors. Pairing and rotation generate an opaque high-entropy token and disclose it exactly once to the local caller; only a SHA-256 verifier is persisted. Local service actions can list metadata, rotate, or revoke clients without returning a token for an existing client.
The allowlist is deliberately small:
| Remote method | Required scope | Capability |
|---|---|---|
status |
status |
Minimal sanitized app/default-pet snapshot |
pet.react |
react |
Allowlisted reaction on the default pet only |
pet.say |
say |
Short validated single-line message on the default pet only |
Remote requests have no lease, install, discovery, file, media, path, prompt, tool-output, or arbitrary-pet-target capability. Remote reactions are not forwarded through LAN pet presence. Existing MCP and CLI commands that use status/react/say can use this mode through the client options or environment; unsupported local-only operations fail with a generic remote-mode error.
When LAN mode is enabled, its mode is initialized before the remote listener and
remote pet.react/pet.say fail closed with shown: false until current LAN
ownership proves that the local host owns the default pet. LAN-disabled mode
preserves the normal local default-pet behavior.
Remote protocol v1 is raw TCP and is not encrypted. A trusted private network is an explicit deployment prerequisite: a network observer can capture and replay the bearer token. Never bind it for public Internet access, use port forwarding, or place it on shared/untrusted Wi-Fi. An encrypted overlay with its own access-control list is strongly preferred. CGNAT-range addressing is only an address classification for the boundary check; it does not provide encryption or confidentiality.
Control Center Setup & Pairing Flow
Control Center provides UI management under Settings → Remote:
- Status & Listener Configuration: Disabled by default. Enabling requires entering an explicit concrete IPv4 bind address and port (no wildcard
0.0.0.0or default autocomplete). - Transport Warning & Acknowledgement: Enabling requires reading a prominent warning regarding raw unencrypted TCP and explicitly checking an acknowledgement of the private network requirement before the listener can be started.
- Paired Client Management: Displays active/revoked clients with scopes (
status,react,say), creation date, and last activity time. - Pairing Flow: Pairing requires a client name and scope selection (
statusandreactrequired;sayunchecked by default). - One-Time Token Handoff: Pair and rotate return the plaintext bearer token exactly once in a dedicated setup modal along with Client ID, environment variable examples (
OPENPETS_REMOTE_ENDPOINT="tcp://<address>:<port>",OPENPETS_REMOTE_CLIENT_ID,OPENPETS_REMOTE_TOKEN), and copy controls. Dismissing the modal immediately clears the token from component state. The token is never stored in plaintext or logged. - Rotation & Revocation: Destructive actions require explicit confirmation modals. Revoking immediately invalidates access for the client ID.
Security
- Local token auth on every local request; the token comes only from the permission-checked local discovery file.
- TCP is private-only. IPv4 addresses only (no hostnames); allowed ranges are
loopback
127.0.0.0/8, private10/8,172.16/12,192.168/16, and link-local169.254/16.0.0.0.0, public IPs, and hostnames are rejected. This is exactly enough to let a WSL client reach the Windows host and nothing more. - Size + timeout caps bound resource use and protect against malformed input.
Contracts
packages/client/contracts/client-protocol.contract.ts- client-side protocol validation.apps/desktop/contracts/local-ipc-protocol.contract.ts- server-side request/response parsing.apps/desktop/contracts/remote-control-protocol.contract.ts- remote allowlist, validation, and secure binding configuration.
These run in the test suite (Testing and validation) and are the guardrail against protocol drift between client and app.
