meadows-client¶
Client-side Socket.IO transport for MEADOWS: connect, reconnect, JWT handshake. No domain logic — shared by
meadows-botand future non-browser clients.
Repository: github.com/remcoboerma/meadows-client
Overview¶
meadows-client wraps python-socketio.AsyncClient with MEADOWS-specific concerns:
- JWT handshake on
/chatnamespace connect - Auto-reconnect (delegated to socketio)
send_message()constructs a valid protocolMessagebefore emittingon(event, handler)for user-registered handlersemit(event, data)escape hatch for non-message eventsregister_label_subscription()/unregister_label_subscription()for label routingon_label_assigned()callback registration for subscription matchescall_rpc()— async RPC via labels (send request, await response)
Install¶
cd meadows-client
uv pip install -e .
Test¶
uv run pytest -q
Usage¶
Pre-signed JWT (recommended)¶
from meadows.client import MeadowClient
from meadows.protocol import EventName, JWTRole, build_claims
client = MeadowClient(
server_url="http://localhost:8080",
claims=build_claims(name="alice", role=JWTRole.USER),
token="<pre-signed-jwt>",
)
client.on(EventName.MESSAGE, lambda data: print("got:", data))
await client.connect()
await client.send_message(content="hello world", group_id="general")
Raw signing key (local dev / TUI only)¶
client = MeadowClient(
server_url="http://localhost:8080",
claims=build_claims(name="alice", role=JWTRole.USER),
jwt_secret=b"<shared key bytes>",
)
Protocol contract¶
This client never sends a frame that violates meadows.protocol. The send_message() method constructs a valid Message envelope before emitting, so the server-side chokepoint never sees an invalid frame from us.
Label subscriptions¶
# Subscribe to labels matching a JSON Logic predicate
client.register_label_subscription(
"sentiment-alerts",
{"regex_match": [{"var": "label"}, "^sentiment$"]},
scope="global",
deliver="label_only",
)
# Handle matched labels
client.on_label_assigned("sentiment-alerts")(lambda data: print(data))
Subscriptions are replayed on reconnect.
RPC via labels¶
# Send an RPC request and await the response (async)
result = await client.call_rpc("service:math", "add 2 3", origin="bot-math-svc")
call_rpc creates an RPC_REQUEST message, routes it via label subscriptions, and resolves when the matching RPC_RESPONSE arrives. Raises asyncio.TimeoutError on timeout.
Schema¶
sequenceDiagram
participant Caller as Client (caller)
participant Server
participant Service as Bot (service)
Caller->>Server: MESSAGE (type=RPC_REQUEST)<br>labels: [("bot-math-svc", "service:math", "1.0.0", {request_id: "a1b2"})]
Note over Caller: await Future(request_id="a1b2")
Server->>Server: Evaluate label subscriptions
Server->>Service: MESSAGE (deliver=message_only)
Service->>Server: MESSAGE (type=RPC_RESPONSE)<br>labels: [("bot-math-svc", "service:math-response", "1.0.0", {request_id: "a1b2"})]
Server->>Caller: MESSAGE (deliver=message_only)
Note over Caller: Future resolves → "5"
Caller-->>Caller: result = "5"
Demo: concurrent calls¶
Multiple call_rpc calls run independently — a slow service doesn't block other calls:
import asyncio
async def demo():
# Both calls start immediately
task_a = asyncio.create_task(client.call_rpc("service:math", "add 1 1", origin="bot-math-svc"))
task_b = asyncio.create_task(client.call_rpc("service:llm", "summarize hello world", origin="bot-llm"))
# They resolve independently — task_b doesn't wait for task_a
result_a = await task_a # "2"
result_b = await task_b # "Hello world is a greeting..."
API¶
async def call_rpc(
self,
service_label: str, # Label to route to (e.g. "service:math")
content: str, # Request payload
*,
origin: str | None = None, # Label origin (defaults to caller identity)
semver: str = "1.0.0", # Label semver
timeout: float = 30.0, # Seconds before TimeoutError
group_id: str = "general", # Group for persistence
) -> str # Response content