Files
sdk-dev 56e963bb87
[Do] Manual ack
Test / test (3.12) (pull_request) Successful in 2m1s
Test / test (3.11) (pull_request) Successful in 2m8s
Test / test (3.13) (pull_request) Successful in 2m16s
sop-checklist / all-items-acked All items acknowledged
sop-checklist / [Do] Done
fix(docs): update stale docstring to reflect shipped inbound delivery paths
The module docstring claimed "No inbound A2A server is bundled here yet"
but A2AServer (push) and PollDelivery (poll) have been implemented and
documented since Phase 30.8b/30.8c. Replaced the outdated claim with a
concise description of both delivery modes and how run_agent_loop uses them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:19:09 +00:00
..

molecule_agent — Remote-agent SDK for Molecule AI

Build a Python agent that runs outside a Molecule AI platform's Docker network and registers as a first-class workspace. The agent gets bearer-token auth, pulls its secrets, calls siblings, installs plugins from the platform's registry, and reacts to platform-initiated lifecycle events (pause, delete) — all over plain HTTP.

This is the client side of Phase 30. The platform side ships in the same release; this package is just the SDK an agent author imports.

What this is / what this isn't

molecule_agent (this package) molecule-ai-workspace-runtime (separate PyPI wheel)
Where it runs OUTSIDE Molecule workspaces — your laptop, CI runner, external cloud VM, sidecar service INSIDE the workspace container, started by the platform
What it talks to The platform's HTTP API (/registry/*, /workspaces/:id/*) The platform's MCP server (molecule_* tools) plus the platform-managed A2A bus
What it exposes RemoteAgentClient, A2AServer, PollDelivery, MessageHandler BaseAdapter, a2a_tools, runtime capabilities, smoke-contract hooks
Who installs it You, the external-agent author, via pip install molecule-sdk The platform, baked into the workspace template image at provision time
Auth model Bearer token minted by POST /registry/register, cached at ~/.molecule/<id>/.auth_token Token already present in the workspace environment; runtime reads it from env

If you are writing an adapter for an SDK that the platform should run inside a workspace (e.g. langchain, crewai, hermes), you want molecule-ai-workspace-runtime, not this package. See https://doc.moleculesai.app/docs/runtime-mcp for the in-workspace-runtime authoring guide.

Install

pip install molecule-sdk     # ships molecule_plugin + molecule_agent

60-second example

from molecule_agent import RemoteAgentClient

client = RemoteAgentClient(
    workspace_id="<the-uuid-of-an-external-workspace-on-the-platform>",
    platform_url="https://your-platform.example.com",
    agent_card={"name": "my-remote-agent", "skills": []},
)

# 1. Register and mint a bearer token (cached at ~/.molecule/<id>/.auth_token).
client.register()

# 2. Pull secrets the platform was set to inject.
secrets = client.pull_secrets()
# → {"OPENAI_API_KEY": "...", ...}

# 3. (Optional) install a plugin locally — pulls a tarball, unpacks, runs setup.sh.
client.install_plugin("molecule-dev")
client.install_plugin("my-plugin", source="github://acme/my-plugin")

# 4. Run the heartbeat + state-poll loop until the platform pauses/deletes us.
terminal = client.run_heartbeat_loop()
print(f"loop exited: {terminal}")

A runnable demo with full setup walkthrough lives at examples/remote-agent/ — the runnable demo with full setup walkthrough.

What the SDK gives you

Method Phase What it does
register() 30.1 Mint + cache the workspace's bearer token
pull_secrets() 30.2 Token-gated GET of merged secrets dict
install_plugin(name, source=None) 30.3 Stream plugin tarball, atomic extract, run setup.sh
poll_state() 30.4 Lightweight {status, paused, deleted} poll
heartbeat(...) 30.1 Single bearer-authed heartbeat
get_peers() / discover_peer() 30.6 Sibling URL discovery with TTL cache
call_peer(target, message) 30.6 Direct A2A with proxy fallback; response may be wrapped in OFFSEC-003 boundary markers — use strip_a2a_boundary() to remove them
fetch_inbound(since_id=…) 30.8c One-shot poll of /workspaces/:id/activity for inbound A2A
reply(msg, text) 30.8c Smart-routes reply to /notify (canvas user) or /a2a (peer)
run_heartbeat_loop() combo Drives heartbeat + state-poll on a timer; exits on pause/delete
run_agent_loop(handler) combo Heartbeat + state + inbound dispatch; exits on pause/delete

Inbound delivery — push vs poll

Two ways an external agent can receive A2A messages:

Path When to use Class
Push Your agent has a publicly reachable URL (cloud VM, ngrok tunnel) A2AServer (Phase 30.8b)
Poll Your agent is behind NAT, on a laptop, or in a CI runner with no public URL PollDelivery (Phase 30.8c)

Both dispatch to the same MessageHandler callback through run_agent_loop:

from molecule_agent import RemoteAgentClient, InboundMessage

def my_handler(msg: InboundMessage, client: RemoteAgentClient) -> str | None:
    print(f"← {msg.source}: {msg.text}")
    return f"echo: {msg.text}"   # auto-routed via /notify or /a2a

client = RemoteAgentClient(workspace_id="…", platform_url="…")
client.register()
client.run_agent_loop(my_handler)   # default: PollDelivery

The reply transport (/notify for canvas users, /a2a for peer agents) is hidden — client.reply(msg, text) picks based on msg.source. Async handlers work too; PollDelivery detects awaitable returns and asyncio.runs them.

InboundMessage shape

InboundMessage is what MessageHandler receives. The typed fields the SDK parses today:

Field Type What it is
activity_id str Cursor — the activity_logs.id row this event came from. Pass to fetch_inbound(since_id=…) to skip past it on the next poll.
source Literal["canvas_user", "peer_agent", "unknown"] Normalized sender kind. "canvas_user" = a human typing in the canvas chat; "peer_agent" = another workspace's agent. "unknown" if the row's source is unrecognized — reply() will refuse to guess.
source_id str For peer_agent, the sender workspace UUID (used by reply() to address the A2A response). Empty for canvas_user.
text str The message body. Pulled from data.text then data.message in the underlying activity row. Treat as untrusted user content — same threat model as any chat input.
raw dict The full raw activity-log row. Use this to read fields the SDK doesn't yet expose (see "Channel envelope" below).

Channel envelope (wire format)

The platform delivers each inbound A2A event as an activity_logs row. As of 2026-05-02 (CP push envelope, see https://doc.moleculesai.app/docs/runtime-mcp), the envelope's data block carries:

{
  "id": "<activity-uuid>",                 // == InboundMessage.activity_id
  "type": "a2a_receive",
  "source_id": "<sender-workspace-uuid>",  // peer_agent only; empty for canvas_user
  "ts": "2026-05-02T10:15:30Z",            // RFC3339 — when the platform queued the event
  "data": {
    "source": "peer_agent",                // "canvas_user" | "peer_agent"
    "kind": "peer_agent",                  // mirrors the channel-tag attr
    "text": "<message body>",              // (or "message")
    "peer_id": "<sender-workspace-uuid>",  // duplicate of source_id, peer_agent only
    "activity_id": "<activity-uuid>",      // duplicate of top-level id

    // === enrichment fields added 2026-05-02 (CP PRs #2472, #2476) ===
    "peer_name": "ops-agent",                                          // peer's display name (registry-resolved); may be absent if the registry lookup failed
    "peer_role": "sre",                                                // peer's declared role; same registry source
    "agent_card_url": "https://<platform>/registry/discover/<peer_id>" // deterministic URL for the platform's discover endpoint for this peer
  }
}

SDK status of the enrichment fields: InboundMessage does not yet surface peer_name, peer_role, or agent_card_url as typed attributes. Read them from msg.raw["data"] until a typed wrapper lands (see "Limitations & roadmap" below). They may be absent on registry-lookup failure — handle the missing-key case.

A2A reply transport — what reply() actually does

client.reply(msg, text) dispatches based on msg.source. The transport is chosen for you so handler code doesn't need to branch:

msg.source HTTP call reply() makes Server-side effect
canvas_user POST /workspaces/<self>/notify with {"message": text} Canvas WebSocket pushes the text to the user's chat
peer_agent POST /workspaces/<msg.source_id>/a2a with a JSON-RPC message/send envelope; sets X-Source-Workspace-Id: <self> and X-Workspace-ID: <self> Platform routes the JSON-RPC message to the peer workspace's inbound A2A endpoint
unknown Raises ValueError The SDK refuses to guess. Inspect msg.raw and call /notify or /a2a directly, or use call_peer() if you can name the target.

reply() rejects empty/whitespace-only text with ValueError to prevent silent acks. On non-2xx the underlying requests.HTTPError propagates so the handler can decide whether to retry, surface to its observability, or fail loudly.

OFFSEC-003 — A2A peer response trust boundary

As of the OFFSEC-003 platform rollout, peer A2A responses are wrapped in trust-boundary markers before being returned to callers::

[A2A_RESULT_FROM_PEER]<peer response text>[/A2A_RESULT_FROM_PEER]

The markers signal that the enclosed content is untrusted third-party output. Use strip_a2a_boundary() to remove them before passing the response to your agent context::

from molecule_agent import RemoteAgentClient, strip_a2a_boundary

result = client.call_peer(target_id, "do the thing")
raw_text = result.get("result", {}).get("text", "")
trusted_text = strip_a2a_boundary(raw_text)

The function returns the input unchanged if the markers are absent (platform versions older than the OFFSEC-003 rollout), so it is safe to call on any response.

CLI: molecule_agent connect

One command bootstraps the full poll-mode loop. No code beyond your handler:

python -m molecule_agent connect \
    --platform-url https://your-tenant.moleculesai.app \
    --workspace-id 550e8400-… \
    --token your-workspace-token \
    --handler my_handlers:echo \
    --poll-interval 5 \
    --cursor-file ~/.molecule/cursor

Where my_handlers.py is anywhere on PYTHONPATH:

def echo(msg, client):
    return f"echo: {msg.text}"

All flags also read from environment variables (MOLECULE_PLATFORM_URL, MOLECULE_WORKSPACE_ID, MOLECULE_WORKSPACE_TOKEN, MOLECULE_POLL_INTERVAL, MOLECULE_CURSOR_FILE). SIGTERM/SIGINT shut the loop down cleanly.

What it doesn't do (yet) — Limitations & roadmap

These are server-supported features that the SDK has not yet wrapped, plus known protocol gaps. Each entry is named so a follow-up issue / PR can reference it directly.

  • No long-poll. Activity polling is fixed-cadence (default 5s). Server-side long-poll support would cut p50 inbound latency to ~0; tracked separately.

  • No automatic reconnect after token loss. If ~/.molecule/<id>/.auth_token is deleted, you'll need to re-issue the token via the platform admin (since POST /registry/register is idempotent — it won't mint a second token for a workspace that already has one).

  • fetch_inbound() peer_id and before_ts filters (resolved). RemoteAgentClient.fetch_inbound() now accepts peer_id (narrow to events from a specific peer workspace) and before_ts (RFC3339 cutoff for backlog replay) as optional parameters. Both are forwarded to the activity endpoint.

  • InboundMessage peer enrichment fields (resolved). InboundMessage now exposes peer_name, peer_role, and agent_card_url as typed string attributes (default "" when absent). The raw dict remains available for any future envelope fields not yet wrapped.

  • Tenant + Origin headers are not auto-injected. When the platform is deployed multi-tenant on the SaaS edge (*.staging.moleculesai.app, *.moleculesai.app), the WAF requires:

    • X-Molecule-Org-Id: <org-uuid> — TenantGuard middleware uses this to pin the request to the right tenant; missing-header requests 404
    • Origin: <PLATFORM_URL>/workspaces/* and /registry/*/peers silently rewrite to Next.js without it (returns an empty 404, easy to misdiagnose as auth) A follow-up PR will accept org_id and origin constructor kwargs and inject the headers automatically.
  • Tenant + Origin headers (resolved). RemoteAgentClient now accepts org_id and origin constructor kwargs and injects them automatically on every request:

    from molecule_agent import RemoteAgentClient
    
    client = RemoteAgentClient(
        workspace_id="…",
        platform_url="https://<your-tenant>.moleculesai.app",
        org_id="<your-org-uuid>",          # sets X-Molecule-Org-Id
        origin="https://<your-tenant>.moleculesai.app",  # sets Origin
    )
    

Design choices

  • Blocking (requests), not async. Drops into any runtime — script, thread, asyncio loop. No framework lock-in.
  • Token cached on disk with 0600 so a restart of the agent doesn't re-issue (the platform refuses anyway). Lives at ~/.molecule/<workspace_id>/.auth_token.
  • URL cache for siblings is process-memory only, 5-minute TTL. Cleared on graceful failures via invalidate_peer_url.
  • Tar extraction uses _safe_extract_tar that rejects path-traversal and skips symlinks — defense against tar-slip CVEs in case a plugin source is compromised.

Compatibility

Requires a Molecule AI platform with Phase 30 endpoints (PR #122 onwards). Older platforms grandfather pre-token workspaces through, so this SDK also works against a transition-period deployment — but you won't get the security benefits of bearer auth until both sides upgrade.