Skip to content

docs(bindings): local API clients and guide - #492

Merged
bahdotsh merged 4 commits into
mainfrom
docs/headless-local-api-clients
Sep 30, 2026
Merged

bahdotsh merged 4 commits into
mainfrom
docs/headless-local-api-clients

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

The user-facing half of the local API: a guide to running the SDK as a service several local applications share, two dependency-free clients, and a test that runs both against an in-process server so the examples cannot drift from the wire.

  • docs/local-api.md, the guide. Purpose first (embed, or run one node that several local programs share), the four things that hold whatever a client does, starting the service on either carrier, the policy file with the failure each section prevents, the hello handshake, sending, subscribing and replay, documents, the one-shot idiom, errors, what is not exposed and why, and the examples. The normative chapter (docs/spec/local-api.md) and the bridge contract (docs/bridges/local-api.md) are linked, not restated. Index row in docs/README.md.
  • bindings/python/examples/local_api_client.py. Python over the Unix socket (--socket) or loopback TCP (--tcp PORT --token-file PATH), using only the websockets package the SDK depends on. --wait sits and receives one inbound message.
  • examples/local-api/client.mjs. Node 22 or later with the built-in WebSocket, no dependencies. The built-in client accepts only ws: and wss: URLs (verified: ws+unix: is refused with "Expected a ws: or wss: protocol"), so it uses the TCP carrier and reads the token file; the header says so.
  • bindings/python/tests/local_api/test_examples.py. Seven tests, see Validation.
  • bindings/python/README.md points at the guide and both examples; CHANGELOG entry under Unreleased / Added.

What each example shows

Both do the same thing in the same order, and print one JSON object per step so a reader (and the test) can follow them:

Step Python Node
hello with app_id (and the token on TCP) yes yes
subscribe to message_received, message_delivered, message_undeliverable yes yes
send_message, then the terminal event naming the returned id, message_delivered or message_undeliverable (--to) yes yes
Document edit: data.create_doc (a no-op on an existing document), data.map_set with a tagged value, data.doc_json read back yes yes
One-shot: a fresh connection that sends hello, one request (local_address), and closes yes yes
Wait for one inbound message_received (--wait) yes no
An engine refusal printed as the JSON-RPC code and data.variant; any other failure as one line with its message yes yes

Two facts the examples had to get right, found by running them: data.map_set takes a tagged value ({"kind":"text","value":...}) while data.doc_json reads back plain JSON, and data.create_doc on an existing document succeeds. The guide states both.

Review round 1

Eight findings from a fresh-context review, all landed in the same commit:

  1. Both examples hung, not failed, when the server closed without answering. The Python reader now fails every pending call with ConnectionError when the socket closes; the Node client rejects every pending call and event wait on close and error. A test drives the Python hello against a server that reads one frame and closes 1011, and the same probe against the Node example is what the mutation check below uses.
  2. Non-RPC failures printed raw tracebacks (a missing socket, a missing token file, a port nothing listens on). Both examples now print one JSON line on stderr and exit 1; a test runs both against nothing. An unreachable --to is watched as message_undeliverable beside message_delivered rather than waiting out the timeout, and the printed key follows the outcome.
  3. Guide: subscribe with "all" resets the filter; unsubscribe with "all" mutes everything.
  4. Guide: the /health body carries api_version too.
  5. Guide: the sender-only correlation of message_sent/message_delivered holds while the server that issued the id is running.
  6. until and two_servers stay duplicated from test_local_api_e2e.py: the shared conftest.py is feat(bindings): the Python reference server for the local API #491's file and that PR is still being edited, so this one does not touch it. Worth folding once feat(bindings): the Python reference server for the local API #491 settles.
  7. Tests: a failing hello no longer leaks the receiver (it moved inside the try), and a subprocess that outlives its deadline is killed rather than left running.
  8. client.mjs refuses to run on a Node without the built-in WebSocket, naming the version, instead of failing with a ReferenceError.

Validation

  • tests/local_api/test_examples.py on python3.12, 3.13 and 3.14: 7 passed on each, and --count 20 on each (140 passed per interpreter, no flake). tests/local_api/ once on 3.14: 61 passed. Run with PYTHONDONTWRITEBYTECODE=1 and bytecode caches purged around every mutant, so a same-size mutant can never survive in a .pyc.
    • The Python example is imported as a module and driven against two in-process servers joined over the peer-stream transport: a message from server A's client is received by server B's client with the sending application id; the document edit reads back and a second edit through the same path shows the newer value; the one-shot call returns the node's address.
    • The example run as a subprocess the way a reader runs it, against server A with --to server B: exit 0, the four printed objects in order, and the message received on B.
    • An engine refusal surfaces by variant (DataDisabled when the data layer is off).
    • Node was present locally (v22.14.0): node --check passes, and the Node example runs end to end against a TCP server with the token file, sending to a second server whose client receives it. Both Node tests skip with a reason when node is not on the path.
  • Mutation-checked: misspelling the Python example's send_message fails two tests; changing the Node example's document key fails the end-to-end test; removing the Python reader's failure of pending calls on close fails the close-hang test; removing the Node client's close listener brings back the review's exit-13 "unsettled top-level await" against the same one-frame-then-close probe, where the shipped code exits 1 with one JSON line naming the close code. All restored from scratch copies and verified byte-identical.
  • Every relative link in the guide and the new README paragraph resolves. No em dashes in any added line. No Rust changed; no Rust guard reads docs/README.md or either example path.

Not in this PR

  • A Swift or Kotlin client example: the service is a Python process and those bindings have no reason to talk to it over a socket.
  • A Unix-socket Node example: the built-in client cannot dial one, and adding a dependency defeats the point of the example.
  • A real deployment run of offline-protocol-service behind a reverse proxy.

Notes for reviewers

One server process fronting one engine for several local applications:
JSON-RPC 2.0 over a WebSocket, a Unix domain socket by default and TCP on
loopback with a per-launch token as the opt-in, a `hello` that declares
the client's application id once and stamps it on every send, and the
engine's events relayed unchanged as notifications.

The chapter partitions the interface definition into the 126 methods a
client may call and the 92 platform operations it never can, including
the run loop and the drain, which the server owns because the engine
delivers and acknowledges a message only from inside the drain. It is
the first complete catalogue of the engine's 75 event tags with their
fields, vocabularies and routing (by application id, by an identifier
the server issued, or to everyone), and it keeps the 25-variant error
taxonomy: the JSON-RPC code is the variant's position in the append-only
enum.

There is no HTTP request path. The pinned WebSocket library's handshake
parser accepts only GET and drops any other method without a response,
which replaces the program record's H5 wording and is recorded in the
chapter so it is not re-added. An optional unauthenticated GET /health
through the request hook is allowed.

docs/bridges/local-api.md states which shared bridge rules a server
inherits (C1, C2, C3, C6, C10, C12) and the new rule that a Rust guard
pins the three tables to the definition, the engine and the reference
server. Index rows added; docs/README.md corrected to thirteen shared
rules and twenty-five ADRs, and given the peer-stream framing row it
lacked.
A guide to running the SDK as a service several local applications share
(docs/local-api.md), a Python client example over the Unix socket or TCP,
a Node client example over TCP with the built-in WebSocket and no
dependencies, and a test that runs both against in-process servers so the
examples cannot drift from the wire. The Node built-in client dials only
ws: and wss: URLs, so that example uses the token-carrying TCP carrier.

Two facts found by running the examples and now stated in the guide:
data.map_set takes a tagged value while data.doc_json reads back plain
JSON, and data.create_doc on an existing document succeeds.
@bahdotsh
bahdotsh force-pushed the docs/headless-local-api-clients branch from 3b43286 to a9df28c Compare September 30, 2026 17:53
@bahdotsh
bahdotsh force-pushed the feat/headless-local-api-server branch from eeb48d9 to 11a94d1 Compare September 30, 2026 19:36
@bahdotsh
bahdotsh changed the base branch from feat/headless-local-api-server to main September 30, 2026 19:48
@bahdotsh bahdotsh closed this Sep 30, 2026
@bahdotsh bahdotsh reopened this Sep 30, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 30, 2026
@bahdotsh
bahdotsh merged commit d4d0033 into main Sep 30, 2026
23 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant