molecule-sdk-python/molecule_agent
Hongming Wang a206dae28b docs(readme): document channel envelope, auth headers, runtime split
Adds the docs gaps that were left when several CP changes landed without
SDK-side coverage:

- "What this is / what this isn't" callout — distinguishes molecule_agent
  (outside-workspace client SDK) from molecule-ai-workspace-runtime
  (in-workspace runtime), with strong forward-pointer to the runtime docs
- Channel envelope (wire format) — including the three enrichment fields
  added 2026-05-02 (peer_name, peer_role, agent_card_url)
- A2A reply transport table — explicit /notify vs /a2a routing per source
- Limitations & roadmap — names the SDK gaps so follow-up issues/PRs
  are trivial to file:
    * fetch_inbound() missing peer_id + before_ts filters (CP PRs #2472, #2476)
    * InboundMessage missing typed peer_name/peer_role/agent_card_url
    * RemoteAgentClient does not auto-inject X-Molecule-Org-Id + Origin
      (with a session-based workaround)

Docs-only — no Python code touched. Code work for the named gaps is
deferred to follow-up PRs so reviewers can land docs first.
2026-05-01 20:06:13 -07:00
..
__init__.py feat: poll-mode inbound delivery + molecule connect CLI (Phase 30.8c) 2026-04-30 13:03:44 -07:00
__main__.py feat: poll-mode inbound delivery + molecule connect CLI (Phase 30.8c) 2026-04-30 13:03:44 -07:00
a2a_server.py feat(sdk): add A2AServer for Phase 30.8b inbound A2A support 2026-04-23 22:50:19 +00:00
client.py feat: poll-mode inbound delivery + molecule connect CLI (Phase 30.8c) 2026-04-30 13:03:44 -07:00
inbound.py fix: address self-review findings — lint + comment + missing test 2026-04-30 13:09:06 -07:00
README.md docs(readme): document channel envelope, auth headers, runtime split 2026-05-01 20:06:13 -07: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 sdk/python/examples/remote-agent/.

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
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.

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() does not expose peer_id or before_ts filters. As of CP PRs #2472 and #2476 (merged 2026-05-02), the platform's GET /workspaces/:id/activity route accepts:

    • peer_id=<uuid> — narrow to events from one specific peer workspace
    • before_ts=<RFC3339> — fetch a backlog window ending before a wall-clock cut-off RemoteAgentClient.fetch_inbound() only forwards since_id, limit, and type today. Workaround: call the activity endpoint directly via client._session.get(...) with the extra params, or filter in-process from the parsed InboundMessage.source_id / InboundMessage.raw["ts"]. A follow-up PR will add typed parameters.
  • InboundMessage does not yet surface peer_name, peer_role, or agent_card_url. These three enrichment fields landed on the platform push envelope on 2026-05-02 and live under msg.raw["data"]. A typed wrapper is the right shape but is intentionally deferred — this README PR is docs-only. Until then, read them from the raw dict and handle the missing-key case (registry lookup may fail for peers that haven't registered yet).

  • 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) RemoteAgentClient does not set either header today — it ships only Authorization: Bearer <token> and per-call X-Workspace-ID / X-Source-Workspace-Id. Workaround: pass a pre-configured requests.Session to the constructor with the headers set globally:
    import requests
    from molecule_agent import RemoteAgentClient
    
    session = requests.Session()
    session.headers.update({
        "X-Molecule-Org-Id": "<your-org-uuid>",
        "Origin": "https://<your-tenant>.moleculesai.app",
    })
    client = RemoteAgentClient(
        workspace_id="…",
        platform_url="https://<your-tenant>.moleculesai.app",
        session=session,
    )
    

    A follow-up PR will accept org_id and origin constructor kwargs and inject the headers automatically.

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.