Runtime & JSON-RPC Protocol
Plugins run as sidecar processes that communicate with the desktop app host via JSON-RPC 2.0 over stdin/stdout. The host spawns your entrypoint, sends requests as newline-delimited JSON on stdin, and reads responses from stdout.
Protocol Basics
Every message follows the JSON-RPC 2.0 format:
Request (host → plugin):
{
"jsonrpc": "2.0",
"id": "request-123",
"method": "plugin/health",
"params": {}
}Response (plugin → host):
{
"jsonrpc": "2.0",
"id": "request-123",
"result": {
"ok": true,
"plugin_id": "your-org.your-plugin",
"version": "0.1.0"
}
}Error response:
{
"jsonrpc": "2.0",
"id": "request-123",
"error": {
"message": "Something went wrong"
}
}Messages are delimited by newlines, one JSON object per line. Your process must read from stdin line-by-line and write responses to stdout with a trailing newline.
Required Methods
Every plugin must implement these two methods:
plugin/health
Health check called by the host to verify your plugin is running.
Request params: {}
Expected response:
{
"ok": true,
"plugin_id": "your-org.your-plugin",
"version": "0.1.0"
}plugin/shutdown
Graceful shutdown request. Your plugin should clean up resources and exit.
Request params: {}
Expected response:
{
"ok": true
}After responding, your process should exit with code 0.
Calling the Host
Your plugin can call host methods by sending a JSON-RPC request on stdout. The host will respond on stdin. This is the reverse direction: your plugin acts as the client.
def host_call(method, params=None):
request_id = f"host-{os.getpid()}-{method}"
send({
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params or {},
})
# Read responses until you find the matching ID
while True:
message = recv()
if str(message.get("id")) == request_id:
if "error" in message:
raise RuntimeError(message["error"]["message"])
return message.get("result", {})Available Host Methods
| Method | Description |
|---|---|
host/ping | Verify bidirectional communication. Returns {"pong": true}. |
host/request_approval | Request user approval for a permission. Params: {"permission": "workspace.read"}. Returns {"approved": true/false}. |
Agent provider plugins have additional host methods available for session management, CLI adapter integration, and approval decisions. See the Codex plugin example for details.
Notifications (Fire-and-Forget)
Your plugin can send notifications to the host as JSON-RPC messages without an id field. These are one-way and don’t expect a response.
{
"jsonrpc": "2.0",
"method": "codex/event",
"params": {
"kind": "notification",
"method": "item/completed",
"params": { "item": { "type": "agentMessage", "text": "Done!" } }
}
}The desktop app listens for these notifications to update the UI in real time (e.g., streaming agent responses).
Message Flow Diagram
Host (Desktop App) Plugin Process
│ │
│──── plugin/health ─────────────────►│
│◄─── {ok: true} ───────────────────│
│ │
│──── your/custom/method ───────────►│
│ │
│ (plugin calls host) │
│◄─── host/request_approval ─────────│
│──── {approved: true} ─────────────►│
│ │
│◄─── result ───────────────────────│
│ │
│──── plugin/shutdown ──────────────►│
│◄─── {ok: true} ───────────────────│
│ exit(0)│Threading Considerations
If your plugin handles long-running operations, you’ll need to handle concurrency. The host may send new requests while you’re processing a previous one.
For Python plugins, use threading to separate the stdin reader from request processing:
import threading
import queue
class HostBridge:
def __init__(self):
self._requests = queue.Queue()
self._pending = {}
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
def _read_loop(self):
while True:
line = sys.stdin.readline()
if not line:
return
message = json.loads(line)
if "method" in message:
# Incoming request from host
self._requests.put(message)
else:
# Response to our outgoing host call
pending = self._pending.pop(str(message.get("id")), None)
if pending:
pending.put(message)
def next_request(self):
return self._requests.get()This pattern lets your plugin make host calls (e.g., host/request_approval) in the middle of processing a request without deadlocking.
Stderr
Anything your plugin writes to stderr is captured by the host as diagnostic output. Use stderr for logging. It won’t interfere with the JSON-RPC protocol on stdout.
import sys
def log(message):
print(message, file=sys.stderr)Never write non-JSON content to stdout. The host expects one valid JSON object per line. Debug output should go to stderr.