From 34d467fe8a87db66a60dc573b9ddb43fcec0178e Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 29 Apr 2026 11:33:31 -0700 Subject: [PATCH 1/2] docs: surface molecule-mcp-claude-channel plugin in external-workspace creation + CONTRIBUTING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third snippet alongside externalCurlTemplate / externalPythonTemplate in workspace-server/internal/handlers/external_connection.go: the new externalChannelTemplate guides operators through installing the Claude Code channel plugin (Molecule-AI/molecule-mcp-claude-channel — scaffolded today) and dropping the .env config for it. Wires the new snippet into the external-workspace POST /workspaces response under key `claude_code_channel_snippet`, alongside the existing `curl_register_template` and `python_snippet`. Canvas's "external workspace created" modal can render it as a third tab. CONTRIBUTING.md gains a short "External integrations" section pointing at the three peer repos (workspace-runtime, sdk-python, mcp-claude-channel) so contributors know where related runtime artifacts live and to consider downstream impact when changing the A2A wire shape. The plugin itself is scaffolded at commit d07363c on the new repo's main branch; v0.1 is polling-based via the /activity?since_secs= filter shipped in PR #2300. README + roadmap details there. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 11 +++++++ .../internal/handlers/external_connection.go | 31 +++++++++++++++++++ .../internal/handlers/workspace.go | 11 +++++++ 3 files changed, 53 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7474110..601533f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -175,6 +175,17 @@ and run CI manually. - Type hints on public functions - pytest for all tests +## External integrations + +Code in this repo lands in molecule-core. Some related runtime artifacts +live in their own repos: + +- [`Molecule-AI/molecule-ai-workspace-runtime`](https://github.com/Molecule-AI/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue. +- [`Molecule-AI/molecule-sdk-python`](https://github.com/Molecule-AI/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow. +- [`Molecule-AI/molecule-mcp-claude-channel`](https://github.com/Molecule-AI/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`. + +When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape. + ## Architecture Overview See `CLAUDE.md` for detailed architecture documentation, including: diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go index 7a73a37b..12a3abfb 100644 --- a/workspace-server/internal/handlers/external_connection.go +++ b/workspace-server/internal/handlers/external_connection.go @@ -67,6 +67,37 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \ }' ` +// externalChannelTemplate — Claude Code channel plugin install + .env. For +// operators whose external agent IS a Claude Code session (laptop or +// remote dev VM); routes the workspace's A2A traffic into the running +// Claude Code session as conversation turns via MCP. The plugin source +// lives at github.com/Molecule-AI/molecule-mcp-claude-channel — polling +// based, no tunnel required (uses /workspaces/:id/activity?since_secs=, +// platform-side support shipped in #2300). +const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your +# Claude Code session. No tunnel/public URL needed (polling-based). +# +# 1. Save this token + workspace_id, then create ~/.claude/channels/molecule/.env: +mkdir -p ~/.claude/channels/molecule +cat > ~/.claude/channels/molecule/.env <<'EOF' +MOLECULE_PLATFORM_URL={{PLATFORM_URL}} +MOLECULE_WORKSPACE_IDS={{WORKSPACE_ID}} +MOLECULE_WORKSPACE_TOKENS= +EOF +chmod 600 ~/.claude/channels/molecule/.env + +# 2. Launch Claude Code with the channel enabled: +claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel + +# Inbound A2A messages now surface as conversation turns. Claude's +# replies route back via the reply_to_workspace MCP tool — no extra +# wiring on your side. +# +# Multi-workspace: comma-separate IDs and tokens (same order). See +# https://github.com/Molecule-AI/molecule-mcp-claude-channel for +# pairing flow, push-mode upgrade, and v0.2 roadmap. +` + // externalPythonTemplate uses molecule-sdk-python's RemoteAgentClient + // A2AServer (PR #13 in that repo). Until the SDK cuts a v0.y release // to PyPI the snippet pins git+main. diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index 723b85b6..00d86b7e 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -370,6 +370,17 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { "{{PLATFORM_URL}}", platformURL), "{{WORKSPACE_ID}}", id, ), + // Claude Code channel plugin snippet. For operators + // whose external agent IS a Claude Code session — + // the snippet sets up ~/.claude/channels/molecule/.env + // and points at the canonical first-party plugin at + // github.com/Molecule-AI/molecule-mcp-claude-channel. + // Polling-based; no tunnel needed. + "claude_code_channel_snippet": strings.ReplaceAll( + strings.ReplaceAll(externalChannelTemplate, + "{{PLATFORM_URL}}", platformURL), + "{{WORKSPACE_ID}}", id, + ), } } c.JSON(http.StatusCreated, resp) From 240d513ab84510c53fec5021feea42aa09a86d87 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 29 Apr 2026 11:39:48 -0700 Subject: [PATCH 2/2] canvas(ExternalConnectModal): add Claude Code tab + auto-fill auth_token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the platform's create-external-workspace response includes `claude_code_channel_snippet` (added in this same PR's first commit), the modal surfaces it as the **first** tab — defaulting to it for new external workspaces because polling-based + no-tunnel is the lowest- friction path. Falls back to Python tab when the field is absent (older platform builds). Type addition is optional (`claude_code_channel_snippet?: string`) so the canvas keeps building against pre-#2304 platform responses during the soak window. Auth-token stamping mirrors existing python/curl behavior — the .env's `MOLECULE_WORKSPACE_TOKENS=` placeholder gets filled in client-side so the copy-paste block is truly ready to run. Also adds the missing 'use client' directive — the file uses useState + useCallback but didn't have the Next.js client-component marker. Pre-commit caught it; existing absence was a latent bug that would surface as an SSR hook error if any path rendered this component during server rendering. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/ExternalConnectModal.tsx | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index 10fc4f85..a10fb54c 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -1,3 +1,5 @@ +'use client'; + // ExternalConnectModal — shown once after creating a runtime="external" // workspace. Surfaces the workspace_auth_token + ready-to-paste snippets // so the operator can hand them to whoever runs their off-host agent @@ -24,6 +26,12 @@ export interface ExternalConnectionInfo { heartbeat_endpoint: string; curl_register_template: string; python_snippet: string; + // Claude Code channel plugin snippet — for operators whose external + // agent IS a Claude Code session. Polling-based; no tunnel required. + // Optional in the type for backward compat with platforms that + // haven't shipped molecule-core PR #2304 yet (older response payload + // omits the field; tab is hidden if empty). + claude_code_channel_snippet?: string; } interface Props { @@ -31,10 +39,14 @@ interface Props { onClose: () => void; } -type Tab = "python" | "curl" | "fields"; +type Tab = "python" | "curl" | "claude" | "fields"; export function ExternalConnectModal({ info, onClose }: Props) { - const [tab, setTab] = useState("python"); + // Default to Claude Code when the platform offers it — that's the + // newest + simplest path (no tunnel needed). Falls back to Python + // for older platform builds that don't ship the snippet. + const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python"; + const [tab, setTab] = useState(initialTab); const [copiedKey, setCopiedKey] = useState(null); const copy = useCallback(async (value: string, key: string) => { @@ -70,6 +82,13 @@ export function ExternalConnectModal({ info, onClose }: Props) { 'WORKSPACE_AUTH_TOKEN=""', `WORKSPACE_AUTH_TOKEN="${info.auth_token}"`, ); + // The channel snippet asks the operator to paste the auth_token into + // the .env file's MOLECULE_WORKSPACE_TOKENS field. Stamp it server-side + // here so the copy-paste-block is truly ready-to-run. + const filledChannel = info.claude_code_channel_snippet?.replace( + 'MOLECULE_WORKSPACE_TOKENS=', + `MOLECULE_WORKSPACE_TOKENS=${info.auth_token}`, + ); return ( !o && onClose()}> @@ -91,7 +110,11 @@ export function ExternalConnectModal({ info, onClose }: Props) { aria-label="Connection snippet format" className="mt-4 flex gap-1 border-b border-zinc-800" > - {(["python", "curl", "fields"] as Tab[]).map((t) => ( + {( + filledChannel + ? (["claude", "python", "curl", "fields"] as Tab[]) + : (["python", "curl", "fields"] as Tab[]) + ).map((t) => ( ))} {/* Snippet area */}
+ {tab === "claude" && filledChannel && ( + copy(filledChannel, "claude")} + /> + )} {tab === "python" && ( copy(filledPython, "python")}