Developer docs/Reference

Wire Protocol v1

Implement a Backend with complete framing, handshake, message, identity, cancellation and lifecycle rules.

Verified September 11, 2026

On this page

Scope and responsibilities#

This reference describes Wire Protocol v1 in the current macOS Host for authors implementing a Backend in another language. The message JSON Schema defines shapes; Service Contracts define business types. Manifest v1, Wire v1, service semantic versions and SDK 0.3 are separate version dimensions.

Wire is the bidirectional Host ↔ Backend connection. WebViews use the SDK Bridge adapter and should not read UDS credentials. Wire governs public capabilities and the Host lifecycle connection; authors still choose their internal UI ↔ Backend technology, names and modules. See internal communication.

Every JSON example below is a frame payload. Add the length prefix before sending. Example IDs, timestamps and session fields are illustrative; use values from the actual connection.

Process environment and credentials#

Host starts a managed Backend with the Curio root as its working directory and supplies:

VariableMeaning
TROVE_SOCKET_PATHHost Unix Domain Socket path; the Backend connects to it
TROVE_SESSION_TOKENAuthentication token for this process session; not a Curio ID
TROVE_CURIO_ID / TROVE_CURIO_VERSIONRunning Curio identity and package version
TROVE_INSTANCE_IDOptional associated UI instance; not ownership of the process
TROVE_DATA_DIR / TROVE_CACHE_DIRHost-assigned Curio data and cache directories
TROVE_PROTOCOL_MIN / TROVE_PROTOCOL_MAXBoth currently 1

For an externally launched dev process, the Rust kit can read socketPath/sessionToken from .trove/dev-session.json selected by TROVE_DEV_SESSION_FILE. First create the external session through trove dev. That file is not reusable installation configuration. Never log, commit or package the token.

One session token permits one active connection. Host binds Curio identity from the token, not an identity chosen in hello. Old sessions cannot be reused after restart, disable, uninstall or development session termination.

Framing and bytes#

text
[length: 4 bytes, unsigned big-endian][payload: length bytes, UTF-8 JSON]
00 00 00 16 7b 22 76 22 3a 31 2c 22 74 79 70 65 22 3a 22 72 65 61 64 79 22 7d
             {"v":1,"type":"ready"}

Length counts UTF-8 JSON bytes, excluding the prefix, not string characters. Empty frames are invalid. The current maximum payload is 8,388,608 bytes (8 MiB). Reject oversized lengths before allocating their declared size. Example Node encoding:

js
function encode(message) {
  const payload = Buffer.from(JSON.stringify(message), "utf8");
  if (!payload.length || payload.length > 8 * 1024 * 1024) {
    throw new Error("Invalid frame length");
  }
  const header = Buffer.alloc(4);
  header.writeUInt32BE(payload.length);
  return Buffer.concat([header, payload]);
}

Accumulate exactly four header bytes, then the declared payload. One read may contain half a header or multiple complete frames. Consume complete frames in a loop and retain the incomplete suffix. Decode UTF-8 JSON as an object and validate v/type and message-specific fields. EOF in a frame is a disconnect; never carry partial input into another session.

Wire does not use stdout, newline-delimited JSON or HTTP headers. Serialize queued writes so concurrent tasks cannot interleave headers and payloads, and handle output backpressure.

hello → welcome → ready#

After connecting, the Backend must send hello first:

json
{
  "v": 1,
  "type": "hello",
  "client": "backend",
  "sessionToken": "REPLACE_WITH_HOST_SESSION_TOKEN",
  "protocol": {
    "min": 1,
    "max": 1
  },
  "features": [
    "call",
    "cancel",
    "subscribe"
  ],
  "implementation": {
    "name": "text-tools",
    "version": "1.0.0",
    "language": "rust"
  }
}
FieldRule
vMessage format version, currently 1
clientMust be backend
sessionTokenUse the Host-issued value; the 16-character minimum is only a structural check, not authentication
protocol.min/maxInclusive supported range, positive integers, min ≤ max, overlapping Host support
featuresUnique strings; call is required, cancel and subscribe reflect actual support
implementationOptional diagnostic metadata; never authentication

Host validates credentials and version overlap, then returns welcome:

json
{
  "v": 1,
  "type": "welcome",
  "protocol": 1,
  "host": {
    "name": "Trove",
    "version": "0.1.0"
  },
  "session": {
    "sessionId": "session-example",
    "curioId": "dev.example.text-tools",
    "curioVersion": "1.0.0",
    "instanceId": null
  },
  "features": [
    "call",
    "cancel",
    "subscribe"
  ],
  "limits": {
    "maxFrameBytes": 8388608,
    "maxInFlight": 256
  }
}

Protocol is the selected version. Features retain mutually supported call/cancel/subscribe; advertising a feature does not mean it was accepted. Session contains authenticated identity; instanceId may be null. Follow the connection limits.

When initialization is complete and requests can be handled, send:

json
{
  "v": 1,
  "type": "ready"
}

Host publishes the connection and releases waiting business calls only after ready. A spawned process is not yet an available service. On handshake failure Host attempts protocolError, then closes; handle EOF even if no error frame arrives.

The gateway currently imposes a separate 10-second limit on the entire hello/welcome/ready handshake. Managed process startup also has Manifest startupTimeoutMs, defaulting to 10 seconds. Increasing startupTimeoutMs alone does not remove the gateway limit.

Requests, responses and correlation#

json
{
  "v": 1,
  "type": "request",
  "id": "backend-call-1",
  "service": "dev.example.text-tools",
  "method": "uppercase",
  "params": {
    "text": "hello"
  },
  "options": {
    "timeoutMs": 5000
  }
}

Either endpoint can send a request. Service selects host.*, a public service ID, or @self for the caller’s own Backend. Cross-Curio callers cannot use @self to address another Curio or discover UI methods.

FieldRule
idNonempty string or signed 64-bit integer; prefer strings to avoid JS precision loss
service / methodNonempty strings; providers reject unknown services or methods
paramsAny JSON; current receivers default omission to {}; send explicitly and validate the business contract
options.timeoutMsOptional nonnegative integer milliseconds; 0 times out immediately, so use positive values
contextOptional Host-generated context; trust rules below

Success and failure responses follow. Include exactly one of result/error; result: null is valid success:

json
{
  "v": 1,
  "type": "response",
  "id": "backend-call-1",
  "result": {
    "text": "HELLO"
  }
}
json
{
  "v": 1,
  "type": "response",
  "id": "backend-call-1",
  "error": {
    "code": "request.invalid_params",
    "message": "text must be a string",
    "data": {
      "field": "text"
    },
    "retryable": false
  }
}

Error requires code and message; data is optional and retryable defaults to false. Wire errors do not guarantee traceId; correlate diagnostics with context.traceId. The SDK may additionally provide traceId. Neither retryable nor the contract’s idempotent flag triggers automatic retries.

Preserve the received ID and its type. Do not reuse IDs for outstanding operations in the same connection direction. Host rewrites forwarded request IDs and restores the caller’s ID on return. Responses can complete out of order: correlate with a pending map, never arrival order. Late responses after timeout/cancellation are discarded.

Identity, parent calls and reverse requests#

This illustrates consumer A → provider B → host.fs. B receives a forwarded request:

json
{
  "v": 1,
  "type": "request",
  "id": "host-provider-1",
  "service": "dev.example.text-tools",
  "method": "uppercaseFile",
  "params": {
    "path": "/tmp/input.txt"
  },
  "options": {
    "timeoutMs": 5000
  },
  "context": {
    "caller": {
      "curioId": "dev.example.consumer",
      "instanceId": "ui-example",
      "endpoint": "ui"
    },
    "traceId": "trace-example",
    "parentRequestId": "host-provider-1",
    "deadlineUnixMs": 1900000005000,
    "hop": 1
  }
}

B sends a reverse request using a new ID and the received context:

json
{
  "v": 1,
  "type": "request",
  "id": "reverse-1",
  "service": "host.fs",
  "method": "readText",
  "params": {
    "path": "/tmp/input.txt"
  },
  "context": {
    "caller": {
      "curioId": "dev.example.consumer",
      "instanceId": "ui-example",
      "endpoint": "ui"
    },
    "traceId": "trace-example",
    "parentRequestId": "host-provider-1",
    "deadlineUnixMs": 1900000005000,
    "hop": 1
  }
}
Context fieldMeaning and boundary
callerCaller authenticated by Host for this hop; curioId/instanceId may be absent, endpoint identifies the ui/backend/Host entry
traceIdTrace ID shared by the parent call chain
parentRequestIdActive request ID sent by Host to this Backend, used to verify reverse-call ancestry
deadlineUnixMsHost-controlled absolute deadline in milliseconds
hopRouting hop count; exceeding 16 returns call.hop_limit

Host inherits trace, deadline, hop and cancellation only when the referenced parent is still active on that Backend connection. It does not trust caller, deadline or hop supplied by the Backend. Without an active parent, the request is independent; an ended chain cannot be forged.

When A calls B and B calls Host, the reverse caller is B. Parent propagation does not impersonate A: Host storage operates in B’s namespace and B’s @self addresses B. B can inspect A’s authenticated identity on the first hop and implement its own business authorization.

Keep the reader active while handlers run so reverse responses and cancel can arrive. Awaiting a handler that depends on a Host response inside the only read loop deadlocks. Rust Context::request handles response correlation and parent-context forwarding.

Total deadlines, cancellation and retry#

A call budget includes SDK connection, provider cold start and execution. Defaults come from the method contract, otherwise typically 30 seconds; file dialogs use five minutes. Explicit SDK timeoutMs accepts 1–2,147,483,647 milliseconds. Wire accepts a wider integer range, which does not guarantee unlimited request duration. Child calls can only shorten the parent’s remaining budget.

Timeout, AbortSignal or caller disconnection ends the wait and attempts to send the executing Backend:

json
{
  "v": 1,
  "type": "cancel",
  "id": "host-provider-1"
}

Cancel targets the original request ID on this connection, not traceId, and has no separate acknowledgment. Notify the task to stop. If the handler subsequently responds, it may use the original ID with request.cancelled; a late result cannot revive a Host wait that already ended.

Cancellation is cooperative: it does not undo completed file writes or forcibly interrupt blocking FFI. Use suitable workers, cancellation checkpoints and cleanup. Canceling one caller also must not terminate a shared provider startup needed by others.

In-flight calls fail on disconnection and are not replayed. Callers decide whether to resubscribe or retry business operations; do not blindly retry side effects.

Subscriptions, events and release#

After negotiating subscribe, establish a subscription with its own request ID. Topic names an events key in the contract:

json
{
  "v": 1,
  "type": "subscribe",
  "id": "subscribe-1",
  "service": "dev.example.text-tools",
  "topic": "progress",
  "params": {
    "taskId": "preview"
  },
  "options": {
    "timeoutMs": 5000
  }
}

The provider assigns a subscriptionId unique on its connection and acknowledges:

json
{
  "v": 1,
  "type": "response",
  "id": "subscribe-1",
  "result": {
    "subscriptionId": "provider-sub-1"
  }
}

Then send events:

json
{
  "v": 1,
  "type": "event",
  "subscriptionId": "provider-sub-1",
  "event": "progress",
  "seq": 1,
  "data": {
    "progress": 0.5
  }
}
FieldRule
subscriptionIdAcknowledged subscription ID, not the subscribe request ID
eventBusiness topic name consistent with the subscription
seqPositive integer; start at 1 and increase; duplicate or decreasing values cause a protocol error
dataJSON matching the event payload schema

Host translates subscription IDs between consumer and provider, maintaining its own consecutive consumer sequence. Providers send acknowledgment before events. Host buffers up to 128 events during acknowledgment forwarding, not an unlimited early-event stream.

Params are provider-implemented filters. Rust emit_for matches caller, service, topic and exactly equal JSON params. Coalesce: latest is currently declarative; Host does not guarantee automatic merging. Events have no history replay or reconnect resumption.

Release with a new request ID:

json
{
  "v": 1,
  "type": "unsubscribe",
  "id": "unsubscribe-1",
  "subscriptionId": "provider-sub-1"
}
json
{
  "v": 1,
  "type": "response",
  "id": "unsubscribe-1",
  "result": {
    "closed": true
  }
}

Host checks ownership, removes routing and notifies the provider. A consumer’s closed result does not mean Host waited for remote cleanup acknowledgment. Close is idempotent; unknown or released subscriptions may return closed: false. Caller disconnection releases its subscriptions too.

On provider loss, Host sends consumers the reserved subscription.closed terminal notification with data.code provider.disconnected, then removes the subscription. The SDK exposes it through the subscription.closed Promise, separate from business events.

Shutdown and window independence#

Host requests provider termination with:

json
{
  "v": 1,
  "type": "shutdown",
  "reason": "host.quit",
  "gracePeriodMs": 2000
}

Reason is diagnostic text, not a fixed allowlist for shutdown. GracePeriodMs is the available cleanup budget. Stop accepting new work, cancel tasks, release subscriptions/resources, flush logs, then send:

json
{
  "v": 1,
  "type": "goodbye"
}

Close the connection and exit. After the grace period Host may terminate the managed process group and children. Externally launched dev Backends receive shutdown too, but Host does not forcibly kill them; they must honor the protocol. EOF also triggers cleanup.

Public Backends are independent of installed UI windows: closing a window does not shut down its public provider, which can continue serving and logging. Private-only on-demand Backends still stop with their window. Closing a development window ends that dev session and its Backend. Host exit, disable, uninstall or configured idle reclamation ends a public provider.

IdleTimeoutMs defaults to zero, disabling idle reclamation. Calls and subscriptions count as activity. Repeated references do not create extra processes or grant callers ownership of a shared process.

Errors and capacity limits#

json
{
  "v": 1,
  "type": "protocolError",
  "error": {
    "code": "protocol.invalid_message",
    "message": "Response requires exactly one of result or error",
    "retryable": false
  }
}

ProtocolError has no request ID and represents a connection-level failure. Business failures use response.error. Fatal protocol errors require clearing pending operations/subscriptions and closing. An error frame is not guaranteed to arrive; handle direct EOF.

Code/limitAction
protocol.invalid_json / invalid_messageCheck encoding, required fields, version and message ordering
protocol.unsupported_versionAlign handshake version ranges
protocol.frame_too_largeReduce payloads; paginate/chunk at the application level
provider.start_timeout / provider.not_readyInspect startup, handshake and ready timing
provider.disconnectedReject pending work and release subscriptions; later calls can restart through Host
service.not_found / method_not_found / version_mismatchCheck registered contracts, methods and requires ranges
request.invalid_params / request.timeout / request.cancelledHandle input, deadlines and cancellation separately
request.too_many_in_flight / transport.backpressureBound concurrency/production rate instead of growing queues
Frame limit8 MiB JSON payload
In-flight limit256 per provider; see welcome.limits
Gateway connectionsCurrently 128
Outbound queuesHost: 512 messages and 16 MiB; Rust kit: 256 messages and 16 MiB, both limits enforced
Subscription initialization bufferUp to 128 events per subscription in Host

The default Host validates messages, registered methods/topics and dependency versions, but does not enable full JSON Schema checking of every business params/result/event payload. Neither TS types nor routing replaces input validation. The Rust kit deserializes typed input; other languages should provide equivalent business validation.

Logs are separate from messages#

Stdout/stderr remain ordinary text. Host collects them into bounded in-memory logs for the current Run. Development exposes them in the terminal and debug console; installed Curios use the same console. Logs are not persisted and have no historical query API. UI console, Backend stdout/stderr and Host lifecycle messages remain separate streams; see debugging.

Host can only read bytes a process has written. C/C++ block-buffered stdout can produce no visible operation logs until exit; enable line buffering or explicitly flush at the source. Shutdown tail output does not mean the operation just occurred. Do not invent Wire messages to carry ordinary logs.

Implementation and interoperability checks#

Download the complete Rust fixture and cases into an isolated Rust starter. The fixture uses existing template dependencies and covers success, structured failure, cancellation, reverse requests and events. Its host.test service exists only in the tester, not the product Host.

sh
trove test
trove test --cases protocol-cases.json

Basic mode checks handshake, fragmentation, eight concurrent unknown-method errors and shutdown/goodbye. Full cases check supplied business operations, independent concurrency, execution cancellation, parent-context forwarding, subscription events and release. Actual Host identity routing still needs integration tests. Passing does not prove every business method correct or replace application tests.

Build a new language adapter in observable steps:

  1. Bounded frame decoding for fragmentation, combined frames, invalid JSON, oversized lengths and EOF.
  2. The hello/welcome/ready state machine, with no business traffic before ready.
  3. An independent reader, concurrent handlers, bidirectional pending maps and unique request IDs.
  4. Deadlines, cooperative cancellation, late response handling and disconnect cleanup.
  5. If needed, subscription acknowledgment, filtering, increasing sequences and idempotent release.
  6. Shutdown grace cleanup, goodbye, child process exit and log flushing.

Finally test with a real Host: acquire a reference from another Curio, verify cold start without a window, call again after closing the provider window, and inspect immediate logs in development and installed runs.