Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.scomp.dev/llms.txt

Use this file to discover all available pages before exploring further.

Scomp messages are JSON-RPC 2.0. That’s the whole framing story — there is no scomp-specific envelope, no length prefix, no custom header. If you can parse JSON-RPC, you can parse scomp.

JSON-RPC 2.0

Every message is a JSON object carrying "jsonrpc": "2.0" plus a request, response, or error shape. Request (every method call from either peer):
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eval",
  "params": { "code": "1 + 1" }
}
Response — success:
{ "jsonrpc": "2.0", "id": 1, "result": { "value": 2 } }
Response — error:
{ "jsonrpc": "2.0", "id": 1,
  "error": { "code": -32000, "message": "ReferenceError: foo is not defined",
             "data": { "kind": "EvalError", "stack": "..." } } }
The id echoes the request. The response carries result or error, never both.

Bidirectional by design

Both peers can originate requests. The client opens with handshake, but after that either side can send invoke at any time. This is required for reverse-invoke — when the server’s runtime needs to call a client-declared binding mid-eval — and it’s why the transport needs to be bidirectional. id is scoped per direction. The client and server each generate ids for the requests they originate. A client request with id: 5 and a server request with id: 5 are unrelated; the response correlation is by direction.

What we don’t use

A few JSON-RPC 2.0 features are deliberately excluded in v0.1:

No notifications

Requests without an id aren’t used. The one exception is release_ref, which is structurally a notification — sent without expecting a response. Everything else carries an id and gets a response.

No batching

Implementations MUST NOT send batched requests and SHOULD reject incoming batches with a JSON-RPC error. Evals serialize on the server anyway; batches gain nothing.
The rationale: with bidirectional requests already in scope, notifications add ambiguity without solving a real problem (cancellation and telemetry are deferred to future revisions). And batches interact badly with bidirectional invoke — if a batch contains two evals and the first triggers reverse-invokes, the resolve order is undefined. Rejecting both keeps semantics unambiguous.

Schemas

All schemas referenced by the protocol — binding input / output, handshake metadata, function-reference input / output — MUST conform to JSON Schema Draft 2020-12. If your validator only supports older drafts, you’re responsible for the conversion. Modern drafts are well-supported in TypeScript, Rust, Python, and most JVM languages.

Transports

A conformant transport provides four things:
1

Bidirectional, message-oriented delivery

Each side can send framed messages independently of the other. No request/response correlation at the transport layer.
2

Reliable, in-order delivery within a connection

Messages sent are received exactly once, in send order. Lossy or out-of-order transports don’t conform.
3

Clear connection boundaries

A well-defined moment when the transport opens, and when it closes. The protocol uses these as anchors for function-reference lifetime.
4

Optional transport-level identity

A way to convey peer credentials (bearer token, mTLS cert, etc.) from the transport into the server’s authorization layer. Used at handshake time.

WebSocket (the blessed transport)

WebSocket [RFC 6455] is the v0.1 blessed transport.
  • Each text frame contains exactly one JSON-RPC message. No batching of messages into a single frame.
  • Binary frames MUST NOT be used.
  • The subprotocol identifier scomp.v0 MAY be advertised on the upgrade; servers SHOULD accept connections without subprotocol negotiation.
  • Authentication piggybacks on the WebSocket upgrade (an Authorization header is typical). The protocol does not validate auth — it relays whatever the transport supplies as opaque handshake metadata.

Other transports

Other transports conform as long as they meet the abstract contract above. An in-process pair (for tests), stdio (for subprocess-hosted runtimes), or a custom TCP framing all work. Such transports SHOULD document their framing rules explicitly and SHOULD reuse the JSON-RPC 2.0 wire format unchanged. A future revision may bless additional transports (stdio is the most likely next addition).

Worked example: a complete handshake

The first round-trip on every connection. The client declares one binding it wants the server to be able to invoke back; the server returns its own bindings and a session id.
// → client to server
{
  "jsonrpc": "2.0", "id": 1, "method": "handshake",
  "params": {
    "protocol": "0.1",
    "bindings": [
      {
        "name": "notify",
        "description": "Push a notification to the user.",
        "input": { "type": "object",
                   "properties": { "message": { "type": "string" } },
                   "required": ["message"] },
        "output": { "type": "null" }
      }
    ],
    "metadata": {
      "client": { "name": "scomp-agent-harness", "version": "0.1.0" }
    }
  }
}
// ← server to client
{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "protocol": "0.1",
    "sessionId": "sess_8f2a3c",
    "bindings": [
      {
        "name": "getOrder",
        "description": "Fetch an order by ID. Returns null if not found.",
        "input": { "type": "object",
                   "properties": { "id": { "type": "string" } },
                   "required": ["id"] },
        "output": { "oneOf": [{ "$ref": "#/$defs/Order" }, { "type": "null" }] },
        "effects": ["read"]
      }
    ],
    "metadata": {
      "server":  { "name": "orders-scomp", "version": "0.1.0" },
      "runtime": { "language": "javascript", "engine": "quickjs" }
    }
  }
}
After this exchange the session is live; the client can submit evals and either side can invoke.

Worked example: an eval with a reverse-invoke

A common shape: the client submits code that calls a server-declared binding (getOrder); the server’s runtime evaluates it, the binding’s return value becomes the eval’s result.
// 1. → eval
{ "jsonrpc": "2.0", "id": 2, "method": "eval",
  "params": { "code": "const o = await getOrder({ id: 'o_001' }); o.total" } }
// 2. ← eval response
{ "jsonrpc": "2.0", "id": 2, "result": { "value": 142.50 } }
If the server’s getOrder handler needed to call back into the client mid-eval — say, asking the client to display a confirmation — you’d see an invoke request interleaved between the two messages above, with its own id independent of the eval’s:
// 1.  → eval (id 2)
// 1a. ← invoke (id 100) — server asks client's "notify" binding
// 1b. → invoke response (id 100)
// 2.  ← eval response (id 2)
The bidirectional independence is what makes this work: the eval’s response doesn’t gate on the invoke’s, and the invoke’s response doesn’t gate on the eval’s. Each side just routes by id, in its own direction.

Read the lifecycle

Lifecycle — the full state machine, eval serialization, and how nested invokes stay deadlock-free.