Appearance
IPC protocol
How the runtime and a plugin backend talk. You never write this by hand — the backend SDK speaks it for you — but understanding the wire format makes logs, timeouts, and size limits legible. Source of truth: runtime/src/ipc and the schemas in packages/protocol-schemas/src/index.ts.
Transport — stdio, newline-delimited JSON
Each plugin runs as a subprocess. The runtime owns its stdin/stdout; the channel is newline-delimited JSON over stdio — one JSON object per line.
- Plugin → runtime: stdout lines prefixed with
IPC:are protocol messages (IPC:{"type":"ready"}). Any other stdout line, and all stderr, is captured by the runtime's log collector. Soconsole.error(...)from a plugin shows up in runtime logs; it never corrupts the protocol. - Runtime → plugin: plain JSON lines on the plugin's stdin.
- Don't read stdin or write raw
IPC:lines yourself — the transport owns both.
Size limit
A single IPC line is capped at MAX_IPC_LINE_BYTES = 4 MB. Consequences:
- An inbound line that exceeds 4 MB with no newline is a fatal framing error — the runtime kills the subprocess (a plugin can't be allowed to exhaust memory buffering one unbounded line).
- Results the runtime sends you (
data.read,kv.list,kv.getMany,data.config) are size-checked before they go out: over the cap, you get a catchableRESPONSE_TOO_LARGEerror instead of a truncated or dropped reply. - Your own handler's reply is not size-checked. If it exceeds the cap it becomes an oversized inbound line at the runtime, which is the fatal framing error above — your backend is SIGKILLed and restarted under the normal crash policy. You do not get a catchable error; you get a dead subprocess and a confusing restart loop if the same request repeats.
This asymmetry is the one to remember: bound your own responses. Paginate or stream large results (cap a list query and pass a cursor) rather than returning everything and hoping it fits.
Message envelope
Every message is an object with a string type. Request/response-style messages also carry a string id used to correlate a reply with its request. Beyond those two fields the envelope is open — actions add their own params without changing the protocol.
Runtime → plugin frames
The SDK dispatcher routes exactly these types (RuntimeToPluginMessageSchema); anything else is silently ignored, so adding a new runtime frame never breaks an old plugin:
type | Meaning | Key fields |
|---|---|---|
request | A user/caller is invoking one of your handle actions. | id, action, params, user {id, displayName, avatarUrl, role}, session_id? |
response | The runtime's reply to a service call you made (db, kv, fetch, …). | id, result?, error? |
event.ack | Acknowledges an events.publish / subscribe. | id, ok, event_id?, error? |
event.deliver | An event-bus delivery to one of your subscriptions. | topic, version, id, ts, source_plugin, payload |
ping | Watchdog heartbeat. SDK auto-replies pong. | — |
file.uploaded | A client finished uploading to your /upload. Fires onFileUploaded. | filename, path, size, mimeType, uploadedBy, uploadedAt |
core.plugin.config_changed | An admin changed one of your settings. Fires settings.onChange. | key, value, changed_by_user_id, ts |
Plugin → runtime frames
The SDK sends these on your behalf. The lifecycle/heartbeat frames:
type | When |
|---|---|
ready | Sent by createPlugin() once the SDK is wired — the ready handshake. |
serve_ready | Sent by serveReady() when caches are warm (opt-in). |
pong | Auto-reply to each ping. |
response | Your handler's result for an inbound request. |
Everything else a plugin sends is a typed service call — a request/response pair where the type names the runtime service and the runtime replies with a response carrying the same id. These map one-to-one onto SDK methods, e.g. data.sql (db), data.kv (kv), data.read, events.publish / events.subscribe / events.unsubscribe, broadcast.toAll / broadcast.toUsers, http.fetch (fetch), schedule.register, presence.*, permissions.*, resources.*, storage.file, voice.tokens / voice.moderation, core.*. Use the SDK — the frame names are an implementation detail listed here only for log-reading.
Capability enforcement
Every typed service call is checked against the plugin's manifest permissions at the runtime boundary, before the service runs. An undeclared capability is a hard reject — the call comes back as an error response, never a partial result. There is no per-call override: if it isn't in the manifest, it cannot happen. This is why the permissions reference maps each SDK feature to its exact capability string.
Error envelope
An error response carries a structured error, not a bare string:
json
{ "type": "response", "id": "…", "error": { "code": "CAPABILITY_DENIED", "message": "…" } }The error object is exactly { code, message } — the schema strips anything else, so don't expect to receive extra diagnostic fields over IPC. The SDK rethrows it as an SdkError whose .code equals the envelope code. Always branch on .code; the message is for humans and must not be parsed.
See also
- Lifecycle — where
ready/ping/pongsit in the boot and watchdog flow, and the message-frames table in context. - Backend SDK — the methods these frames implement.