MeshClient — swarph_mesh.mesh_client

Async HTTP wrapper over the mesh-gateway. Pairs with MeshMessage / MeshPeer wire types.

Tier 1 stability. send / fetch signatures and MeshMessage wire shape are part of the contract.

MeshClient — async wrapper around the mesh-gateway HTTP API per PLAN.md §5.

Replaces the hand-rolled curl-based code in:

  • lab-orchestrator/scripts/lab_loop_drain.py

  • hedge-fund-mcp/scripts/mesh_inbox_watcher.py (droplet-side)

  • hedge-fund-mcp/scripts/science_claude_inbox_drain.py

  • assorted ad-hoc curl invocations in CLAUDE.md / session ritual

with a single typed surface so cross-Claude DM coordination doesn’t require re-discovering the gateway shape from scratch every time.

Public surface:

client = MeshClient(node=”lab-ovh”, token=os.environ[“MESH_GATEWAY_TOKEN”]) peers = await client.list_peers() msgs = await client.fetch(unread_only=True) sent = await client.send(to=”droplet”, kind=”fyi”, content=”…”) await client.mark_read(msg_id=123) await client.register(url=”http://lab-ovh:8787”, capabilities={…})

Two structural invariants enforced:

  1. Recipient name validation. Every send() calls swarph_shared.validate_node_name on the recipient — closes the Vector A (peer-onboarding chatter) + Vector B (human-prompt shorthand) framing-contagion classes documented in project_peer_name_canonical.md.

  2. Mesh-secrets out-of-band guard. Every send() runs a best-effort regex sniff on content for obvious credentials (pypi-..., sk-..., gh[ops]_..., eyJ... JWTs). Hits raise MeshSecretLeakError BEFORE the POST. CLAUDE.md “Mesh secrets out-of-band only — NEVER ride /messages content fields” is the non-negotiable rule. Best-effort because regex misses are common — operators must still treat content fields as public. The guard catches the obvious cases.

exception swarph_mesh.mesh_client.MeshGatewayError[source]

Bases: SwarphMeshError

Gateway returned non-2xx or otherwise non-conformant response.

exception swarph_mesh.mesh_client.MeshAuthError[source]

Bases: MeshGatewayError

Authentication failed — token missing or rejected (401/403).

exception swarph_mesh.mesh_client.MeshSecretLeakError[source]

Bases: SwarphMeshError

Best-effort guard caught an apparent credential in DM content.

Refuses to send. Operators must rotate the credential and resend without it.

class swarph_mesh.mesh_client.MeshClient(node, *, token=None, gateway_url=None, timeout_seconds=10.0, validate_self_name=True)[source]

Bases: object

Async client for the mesh-gateway HTTP API.

Construct once per peer (or per cycle), reuse for the lifetime of the work. Backed by httpx.AsyncClient so connection-pooling happens for free across many send() calls.

Use as an async context manager when you want explicit close:

async with MeshClient(node=”lab-ovh”) as client:

await client.send(to=”droplet”, kind=”fyi”, content=”…”)

Or instantiate normally + call aclose() when done.

Parameters:
  • node (str)

  • token (Optional[str])

  • gateway_url (Optional[str])

  • timeout_seconds (float)

  • validate_self_name (bool)

async aclose()[source]
Return type:

None

async list_peers()[source]

GET /peers — return all registered peers.

Return type:

list[MeshPeer]

async register(*, url=None, capabilities=None)[source]

POST /peers/register — register this peer with the gateway.

Parameters:
Return type:

MeshPeer

async fetch(*, to_node=None, unread_only=False, limit=None)[source]

GET /messages — fetch own inbox.

Defaults to_node to self.node. Pass an explicit value only if you have a legitimate reason to peek at another peer’s inbox (gateway access-controls this; most callers will get their own inbox only).

v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build): the mesh-gateway query parameter is ?to= — NOT ?to_node=. The latter is silently ignored, returning ALL recent messages regardless of recipient. Pre-fix every MeshClient.fetch caller saw outbound bleed-through unless they Python-side filtered. The kwarg keeps its descriptive name for the caller-facing API; only the wire-shape changes.

Parameters:
  • to_node (str | None)

  • unread_only (bool)

  • limit (int | None)

Return type:

list[MeshMessage]

async send(*, to, kind, content, related_task_id=None, thread_id=None, skip_secret_check=False)[source]

POST /messages — send a DM.

Validates to via swarph_shared.validate_node_name (closes Vector A + B contagion classes); raises ValueError / NotInRegistry on invalid recipient names BEFORE the POST.

kind is a Literal enum (see DM_KIND above) — type checkers reject unknown values at lint time, and the runtime guard below catches anyone passing kind as str-typed from a callsite that bypassed type-checking.

Runs the best-effort credential leak detector on content. Set skip_secret_check=True only when content has been inspected by the caller and the leak detector is producing a false positive (e.g. discussing a credential format in prose). False positives should be rare; treat them as a signal that the content is on a fence and rotate aggressively if you’re not 100% sure.

Parameters:
  • to (str)

  • kind (Literal['fyi', 'question', 'answer', 'status', 'unblock'])

  • content (str)

  • related_task_id (str | None)

  • thread_id (str | None)

  • skip_secret_check (bool)

Return type:

MeshMessage

async mark_read(msg_id)[source]

POST /messages/{msg_id}/read — flip read_at on a DM.

Per CLAUDE.md droplet-side mesh DM drain rule, the cron-side watcher does NOT mark read; only the commander or the peer-in-session does. Adapter callers consuming inboxes should mark-read explicitly when they’ve actually processed the DM, not on fetch.

Parameters:

msg_id (int)

Return type:

None

Wire types — swarph_mesh.mesh_types

Typed dataclasses for mesh-gateway responses.

Shape derived from the live gateway at http://lab-ovh:8788 (2026-05-08). Stable enough to type against; Phase 3+ migrations on the gateway side will add fields rather than rename existing ones (per the swarph conventions captured in CLAUDE.md hedge-fund-mcp).

class swarph_mesh.mesh_types.MeshPeer(*, name, url=None, capabilities=<factory>, enabled=True, last_health=None, last_seen=None, registered_at=None, **extra_data)[source]

Bases: BaseModel

One row from GET /peers.

Parameters:
  • name (str)

  • url (str | None)

  • capabilities (dict[str, Any])

  • enabled (bool)

  • last_health (str | None)

  • last_seen (str | None)

  • registered_at (str | None)

  • extra_data (Any)

name: str
url: str | None
capabilities: dict[str, Any]
enabled: bool
last_health: str | None
last_seen: str | None
registered_at: str | None
model_config = {'extra': 'allow'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class swarph_mesh.mesh_types.MeshMessage(*, id, from_node, to_node, kind, content=None, created_at, read_at=None, related_task_id=None, thread_id=None, **extra_data)[source]

Bases: BaseModel

One row from GET /messages (or the success response of POST /messages).

v0.5.1 fix (drop DM #722 + #728): content is now Optional[str] = None. The gateway’s POST /messages success response returns {id, from_node, to_node, kind, thread_id, created_at} — no content field. Pre-fix, MeshClient.send raised pydantic.ValidationError on every successful POST despite the message landing cleanly, trapping callers wrapping send() in try/except into thinking the send failed and retrying — a DM duplication risk. The content-required model was correct for GET /messages rows (where content always present) but wrong for POST responses; making it optional fits both shapes.

Parameters:
  • id (int)

  • from_node (str)

  • to_node (str)

  • kind (str)

  • content (str | None)

  • created_at (str)

  • read_at (str | None)

  • related_task_id (str | None)

  • thread_id (str | None)

  • extra_data (Any)

id: int
from_node: str
to_node: str
kind: str
content: str | None
created_at: str
read_at: str | None
related_task_id: str | None
thread_id: str | None
model_config = {'extra': 'allow'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].