User report: handing the modal's Claude Code channel snippet to an
agent fails immediately with two errors that the snippet doesn't tell
the operator how to resolve:
plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · plugin not installed
plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · not on the approved channels allowlist
Root cause: the snippet's `claude --channels plugin:...` line assumes
the plugin is pre-installed AND that the channel is on Anthropic's
default allowlist. Both assumptions are wrong for a custom Molecule
plugin in a public repo.
Two changes:
1. Rewrite externalChannelTemplate (Go) with full setup chain:
- Bun prereq check (channel plugins are Bun scripts)
- `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel`
+ `/plugin install molecule@molecule-mcp-claude-channel` BEFORE the
launch — otherwise "plugin not installed"
- `--dangerously-load-development-channels` flag on launch — required
for non-Anthropic-allowlisted channels, otherwise "not on approved
channels allowlist"
- Common-errors block at the bottom mapping each error string to
which numbered step recovers it
- Team/Enterprise managed-settings caveat (the dev-channels flag is
blocked there; admin must use channelsEnabled + allowedChannelPlugins)
Plugin install info verified by reading `Molecule-AI/molecule-mcp-claude-channel`
plugin.json (`name: "molecule"`) and the Claude Code channels +
plugin-discovery docs at code.claude.com/docs/en/{channels,discover-plugins}.
2. Add per-tab HelpBlock to the modal (canvas):
- Collapsible <details> below each snippet, closed by default so the
snippet stays the visual focus
- "Where to install" link (PyPI for runtime, claude.com for Claude
Code, github.com/openai/codex for Codex, NousResearch/hermes-agent
for Hermes)
- "Documentation" link (docs.molecule.ai/docs/guides/*; hostname
confirmed by existing blog post canonical metadata; paths map
1:1 to docs/guides/*.md files in this repo)
- "Common errors" list with concrete recovery steps for each tab
(e.g. Codex tab calls out the codex≥0.57 requirement and TOML
duplicate-table parse error; OpenClaw calls out the :18789 port
conflict check)
URL discipline: every URL is either (a) verified against a file path
in this repo's docs/, (b) the canonical repo of an existing snippet
reference, or (c) a well-known third-party canonical URL. No guessed
URLs — broken links would defeat the purpose of "more comprehensive
instructions."
Verification:
- `go build ./...` clean in workspace-server
- `go test ./internal/handlers/...` passes (4.3s)
- Bash syntax check on test_staging_full_saas.sh (no edits there) clean
- TS brace/paren/bracket counts balanced; no full tsc run because the
worktree's node_modules isn't installed — counterpart Canvas tabs E2E
on the PR will exercise the full type-check + render path
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
409 lines
17 KiB
Go
409 lines
17 KiB
Go
package handlers
|
|
|
|
// external_connection.go — copy-paste connection payload shown once to
|
|
// the operator when they create a runtime="external" workspace.
|
|
//
|
|
// The canvas UI surfaces these in a single modal so the operator can
|
|
// hand the block to whoever runs their external agent without having
|
|
// to piece together workspace_id + platform_url + auth_token + API
|
|
// shape from the docs. curl snippet has zero dependencies; Python
|
|
// snippet pairs with molecule-sdk-python's A2AServer + RemoteAgentClient.
|
|
|
|
import (
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// externalPlatformURL returns the public URL at which this workspace-
|
|
// server instance is reachable by the operator's external agent. This
|
|
// is NOT necessarily the caller's Host header (which could be an
|
|
// internal CF tunnel hostname). Prefer the EXTERNAL_PLATFORM_URL env
|
|
// that Railway/ops sets for the tenant; fall back to the request's
|
|
// Host + scheme if unset.
|
|
func externalPlatformURL(c *gin.Context) string {
|
|
if v := os.Getenv("EXTERNAL_PLATFORM_URL"); v != "" {
|
|
return v
|
|
}
|
|
scheme := "https"
|
|
if xf := c.Request.Header.Get("X-Forwarded-Proto"); xf != "" {
|
|
scheme = xf
|
|
} else if c.Request.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
host := c.Request.Host
|
|
if xh := c.Request.Header.Get("X-Forwarded-Host"); xh != "" {
|
|
host = xh
|
|
}
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
// externalCurlTemplate — zero-dependency register snippet. Placeholders:
|
|
// - {{PLATFORM_URL}}, {{WORKSPACE_ID}} — filled server-side
|
|
// - $WORKSPACE_AUTH_TOKEN — env var, operator sets
|
|
// - $AGENT_URL — env var, operator's public HTTPS endpoint
|
|
//
|
|
// SSRF filter rejects private IPs at register time, so AGENT_URL must
|
|
// resolve to a public host.
|
|
//
|
|
// Heartbeat loop is NOT included here — curl is fine for one-shot
|
|
// register; keeping the workspace alive wants a real loop, so point
|
|
// operators at the Python snippet for long-lived setups.
|
|
const externalCurlTemplate = `# Replace AGENT_URL with YOUR agent's public HTTPS endpoint, then run:
|
|
export WORKSPACE_AUTH_TOKEN="<paste from create response>"
|
|
export AGENT_URL="https://your-agent.example.com"
|
|
|
|
# NOTE on the "Origin" header below: hosted SaaS tenants run behind an
|
|
# edge WAF that requires same-origin requests. Without "Origin", paths
|
|
# like /workspaces/* silently 404 (rewritten to the canvas Next.js).
|
|
# /registry/register is currently allowed without Origin, but setting
|
|
# it preemptively keeps your snippet working if the WAF rules expand.
|
|
curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
|
|
-H "Authorization: Bearer $WORKSPACE_AUTH_TOKEN" \
|
|
-H "Origin: {{PLATFORM_URL}}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"id": "{{WORKSPACE_ID}}",
|
|
"url": "'"$AGENT_URL"'",
|
|
"agent_card": {
|
|
"name": "My External Agent",
|
|
"description": "",
|
|
"version": "0.1.0"
|
|
}
|
|
}'
|
|
`
|
|
|
|
// 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).
|
|
#
|
|
# Prereq: Bun installed (channel plugins are Bun scripts).
|
|
# bun --version # must print a version number
|
|
#
|
|
# 1. Inside Claude Code, install the channel plugin from its GitHub repo.
|
|
# The plugin is NOT on Anthropic's default allowlist, so a one-time
|
|
# marketplace-add is needed before install:
|
|
#
|
|
# /plugin marketplace add Molecule-AI/molecule-mcp-claude-channel
|
|
# /plugin install molecule@molecule-mcp-claude-channel
|
|
#
|
|
# Then either run /reload-plugins or restart Claude Code so the
|
|
# plugin is registered.
|
|
#
|
|
# 2. Create the per-watched-workspace config file:
|
|
mkdir -p ~/.claude/channels/molecule
|
|
cat > ~/.claude/channels/molecule/.env <<'EOF'
|
|
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
|
|
MOLECULE_WORKSPACE_IDS={{WORKSPACE_ID}}
|
|
MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
|
|
EOF
|
|
chmod 600 ~/.claude/channels/molecule/.env
|
|
|
|
# 3. Launch Claude Code with the channel enabled. Custom (non-Anthropic-
|
|
# allowlisted) channels need the --dangerously-load-development-channels
|
|
# flag to opt in — without it, you'll see "not on the approved channels
|
|
# allowlist" on startup.
|
|
claude --dangerously-load-development-channels \
|
|
--channels plugin:molecule@molecule-mcp-claude-channel
|
|
|
|
# You should see on stderr:
|
|
# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}}
|
|
#
|
|
# 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.
|
|
#
|
|
# Common errors:
|
|
# "plugin not installed" → Step 1 didn't run; run /plugin install
|
|
# inside Claude Code, then /reload-plugins.
|
|
# "not on approved channels allowlist" → Add --dangerously-load-development-channels
|
|
# to the launch command (Step 3).
|
|
# "config-missing" → ~/.claude/channels/molecule/.env not
|
|
# readable; re-run Step 2 and check chmod.
|
|
#
|
|
# Team/Enterprise orgs: the --dangerously-load-development-channels flag is
|
|
# blocked by managed settings. Your admin must set channelsEnabled=true and
|
|
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
|
|
#
|
|
# 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.
|
|
`
|
|
|
|
// externalUniversalMcpTemplate — runtime-agnostic standalone path.
|
|
// Ships as the `molecule-mcp` console script in the
|
|
// molecule-ai-workspace-runtime PyPI wheel (workspace/mcp_cli.py).
|
|
// Any MCP-aware runtime (Claude Code, hermes, codex, third-party)
|
|
// registers it once and gets the same 8 universal tools that
|
|
// container-bound runtimes use today: delegate_task, list_peers,
|
|
// send_message_to_user, commit_memory, etc.
|
|
//
|
|
// Standalone: the binary itself handles register-on-startup +
|
|
// continuous heartbeats (daemon thread, 20s cadence). No separate
|
|
// SDK or channel process needed to keep the workspace online. The
|
|
// only thing it does NOT yet do is poll inbound A2A messages — for
|
|
// runtimes that need their agent to react to canvas messages or
|
|
// peer-initiated tasks, pair with the Claude Code channel tab
|
|
// (poll-based inbound delivery into a Claude Code session) or the
|
|
// Python SDK tab (push-mode inbound + heartbeat).
|
|
//
|
|
// Origin/WAF: handled automatically by platform_auth.auth_headers()
|
|
// in the wheel — operator doesn't need to configure anything.
|
|
const externalUniversalMcpTemplate = `# Universal MCP — standalone register + heartbeat + outbound platform tools
|
|
# for any MCP-aware runtime (Claude Code, hermes, codex, etc.).
|
|
# Pair with the Claude Code or Python SDK tab if your runtime needs
|
|
# inbound A2A delivery (canvas messages → agent conversation turns).
|
|
|
|
# 1. Install the workspace runtime wheel:
|
|
pip install molecule-ai-workspace-runtime
|
|
|
|
# 2. Wire molecule-mcp into your agent's MCP config. Claude Code:
|
|
claude mcp add molecule -s user -- env \
|
|
WORKSPACE_ID={{WORKSPACE_ID}} \
|
|
PLATFORM_URL={{PLATFORM_URL}} \
|
|
MOLECULE_WORKSPACE_TOKEN="<paste from create response>" \
|
|
molecule-mcp
|
|
|
|
# molecule-mcp registers the workspace + heartbeats every 20s in a
|
|
# daemon thread, then runs the MCP stdio loop. Same env-var contract
|
|
# works with hermes-agent, codex, or any MCP stdio runtime. Tools
|
|
# exposed: delegate_task, delegate_task_async, check_task_status,
|
|
# list_peers, get_workspace_info, send_message_to_user,
|
|
# commit_memory, recall_memory.
|
|
#
|
|
# Origin/WAF handling is built into the wheel — no manual headers
|
|
# needed when calling tools through the MCP server.
|
|
`
|
|
|
|
// 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.
|
|
const externalPythonTemplate = `# pip install 'git+https://github.com/Molecule-AI/molecule-sdk-python.git@main'
|
|
|
|
import asyncio
|
|
from molecule_agent import RemoteAgentClient, A2AServer
|
|
|
|
WORKSPACE_ID = "{{WORKSPACE_ID}}"
|
|
PLATFORM_URL = "{{PLATFORM_URL}}"
|
|
AUTH_TOKEN = "<paste from create response>"
|
|
INBOUND_URL = "https://your-agent.example.com/a2a/inbound" # your public HTTPS endpoint
|
|
|
|
async def handle(request: dict) -> dict:
|
|
# request has parts, message, task_id, idempotency_key
|
|
text = "".join(p.get("text", "") for p in request.get("parts", []) if p.get("type") == "text")
|
|
return {"parts": [{"type": "text", "text": f"echo: {text}"}]}
|
|
|
|
async def main():
|
|
client = RemoteAgentClient(
|
|
workspace_id=WORKSPACE_ID,
|
|
platform_url=PLATFORM_URL,
|
|
auth_token=AUTH_TOKEN,
|
|
)
|
|
server = A2AServer(
|
|
agent_id=client.workspace_id,
|
|
inbound_url=INBOUND_URL,
|
|
message_handler=handle,
|
|
)
|
|
server.start_in_background()
|
|
client.reported_url = INBOUND_URL
|
|
client.register() # one-shot announcement
|
|
await client.run_heartbeat_loop_async() # keeps the workspace online
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|
|
`
|
|
|
|
// externalHermesChannelTemplate — install snippet for operators whose
|
|
// external agent IS a hermes-agent session. Routes the workspace's
|
|
// A2A traffic into the running hermes gateway as platform messages
|
|
// via the molecule-channel plugin.
|
|
//
|
|
// The plugin (Molecule-AI/hermes-channel-molecule) is a hermes
|
|
// platform adapter that:
|
|
// 1. Spawns ``python -m molecule_runtime.a2a_mcp_server`` as a
|
|
// stdio MCP subprocess (separate from any hermes-side MCP
|
|
// client connection).
|
|
// 2. Long-polls ``wait_for_message`` on the platform's inbox.
|
|
// 3. Dispatches each inbound activity into the hermes gateway as a
|
|
// MessageEvent — same code path Telegram/Discord use.
|
|
// 4. Outbound replies route via ``send_message_to_user`` (canvas
|
|
// user) or ``delegate_task`` (peer agent) MCP tool calls.
|
|
//
|
|
// Result: hermes gets push parity with Claude Code / codex / openclaw —
|
|
// canvas messages and peer A2A arrive as conversation turns mid-session,
|
|
// not just at the start of a new ``hermes`` invocation.
|
|
//
|
|
// Plugin uses the upstream ``register_platform`` API shipped by
|
|
// NousResearch/hermes-agent#17751 (merged 2026-04-30) and falls back
|
|
// to the legacy ``register_platform_adapter`` shape on older forks —
|
|
// same wheel installs cleanly on stock or patched hermes-agent.
|
|
const externalHermesChannelTemplate = `# Hermes channel — bridges this workspace's A2A traffic into your
|
|
# hermes-agent session. No tunnel/public URL needed (long-poll based,
|
|
# same shape as the Claude Code channel).
|
|
#
|
|
# Prereq: a hermes-agent install on the target machine. Latest builds
|
|
# (post #17751) ship the platform-plugin API natively; older ones are
|
|
# also supported via the plugin's dual-mode fallback.
|
|
#
|
|
# 1. Install the runtime + plugin:
|
|
pip install molecule-ai-workspace-runtime
|
|
pip install 'git+https://github.com/Molecule-AI/hermes-channel-molecule.git'
|
|
|
|
# 2. Export the workspace credentials:
|
|
export MOLECULE_WORKSPACE_ID={{WORKSPACE_ID}}
|
|
export MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
|
|
export MOLECULE_WORKSPACE_TOKEN="<paste from create response>"
|
|
export MOLECULE_ORG_ID="<your org id>"
|
|
|
|
# 3. Edit ~/.hermes/config.yaml — under your existing top-level
|
|
# gateway: block, add a plugin_platforms entry:
|
|
#
|
|
# gateway:
|
|
# # ...your existing gateway settings...
|
|
# plugin_platforms:
|
|
# molecule:
|
|
# enabled: true
|
|
#
|
|
# If you don't yet have a gateway: block, create one with just
|
|
# that plugin_platforms entry. Don't append blindly — YAML
|
|
# rejects duplicate top-level keys, so a second gateway: block
|
|
# will silently break hermes config loading.
|
|
|
|
# 4. Restart the hermes gateway:
|
|
hermes gateway --replace
|
|
|
|
# Inbound canvas messages + peer A2A now arrive as MessageEvents —
|
|
# same dispatch path Telegram/Discord/Slack use. The agent replies via
|
|
# send_message_to_user / delegate_task MCP tool calls (already wired
|
|
# by the plugin's molecule_runtime MCP subprocess).
|
|
#
|
|
# Source + issue tracker:
|
|
# https://github.com/Molecule-AI/hermes-channel-molecule
|
|
`
|
|
|
|
// externalCodexTemplate — for operators whose external agent is a
|
|
// codex CLI (@openai/codex) session. Wires the molecule_runtime A2A
|
|
// MCP server into codex's config.toml so the agent can call
|
|
// list_peers / delegate_task / send_message_to_user / commit_memory.
|
|
//
|
|
// Push parity caveat: codex's MCP client doesn't forward arbitrary
|
|
// notifications/* from configured MCP servers (verified by reading
|
|
// codex-rs/codex-mcp/src/connection_manager.rs in openai/codex). So
|
|
// this snippet gives outbound tools but NOT mid-turn push from
|
|
// inbound A2A. For full push parity on a codex external, the
|
|
// equivalent of hermes-channel-molecule would be needed — a bridge
|
|
// daemon that long-polls the platform inbox and calls codex's
|
|
// turn/steer RPC. Tracked separately; this snippet is the
|
|
// outbound-tool-only first cut.
|
|
const externalCodexTemplate = `# Codex MCP config — outbound tool path. For operators whose external
|
|
# agent is a codex CLI (@openai/codex) session.
|
|
#
|
|
# This wires the molecule platform's A2A MCP server into codex so
|
|
# the agent can call list_peers / delegate_task / send_message_to_user
|
|
# / commit_memory. Inbound A2A (canvas messages, peer-initiated tasks)
|
|
# does NOT push into the running codex turn yet — codex's MCP runtime
|
|
# doesn't route arbitrary notifications/* from configured MCP servers.
|
|
# For inbound delivery into a codex session, pair with the Python SDK
|
|
# tab for now.
|
|
|
|
# 1. Install codex CLI + the workspace runtime wheel:
|
|
npm install -g @openai/codex@^0.57
|
|
pip install molecule-ai-workspace-runtime
|
|
|
|
# 2. Edit ~/.codex/config.toml and add the block below. {{PLATFORM_URL}}
|
|
# and {{WORKSPACE_ID}} are stamped server-side; paste your auth
|
|
# token for MOLECULE_WORKSPACE_TOKEN before saving.
|
|
#
|
|
# Don't append blindly — TOML rejects duplicate
|
|
# [mcp_servers.molecule] tables, so re-running on an existing
|
|
# config will break codex parsing. If [mcp_servers.molecule]
|
|
# already exists (e.g. you set this up before), replace the
|
|
# existing block instead of appending.
|
|
|
|
mkdir -p ~/.codex
|
|
# (then open ~/.codex/config.toml in your editor and paste:)
|
|
#
|
|
# [mcp_servers.molecule]
|
|
# command = "python3"
|
|
# args = ["-m", "molecule_runtime.a2a_mcp_server"]
|
|
# startup_timeout_sec = 30
|
|
#
|
|
# [mcp_servers.molecule.env]
|
|
# WORKSPACE_ID = "{{WORKSPACE_ID}}"
|
|
# PLATFORM_URL = "{{PLATFORM_URL}}"
|
|
# MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"
|
|
# MOLECULE_ORG_ID = "<your org id>"
|
|
|
|
# 3. Run codex — the molecule tools are now available to the agent:
|
|
codex
|
|
`
|
|
|
|
// externalOpenClawTemplate — for operators whose external agent is an
|
|
// openclaw session. Wires the molecule MCP server via openclaw's
|
|
// `mcp set` config + starts the openclaw gateway on loopback.
|
|
//
|
|
// Like the codex tab, this is outbound-only. Full push parity on an
|
|
// external openclaw would need a sessions.steer bridge daemon (the
|
|
// equivalent of hermes-channel-molecule for openclaw). Tracked
|
|
// separately; outbound tools is the first cut.
|
|
const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path. For operators whose
|
|
# external agent is an openclaw session.
|
|
#
|
|
# This wires the molecule platform's A2A MCP server into openclaw's
|
|
# gateway so the agent can call list_peers / delegate_task /
|
|
# send_message_to_user / commit_memory. Inbound A2A push into a
|
|
# running openclaw run is not wired here yet — the platform-side
|
|
# openclaw template (template-openclaw) implements the full
|
|
# sessions.steer push path; an external setup would need the same
|
|
# bridge daemon the template uses. For inbound delivery on an
|
|
# external machine today, pair with the Python SDK tab.
|
|
|
|
# 1. Install openclaw CLI + the workspace runtime wheel:
|
|
npm install -g openclaw@latest
|
|
pip install molecule-ai-workspace-runtime
|
|
|
|
# 2. Onboard openclaw against your model provider (one-time setup).
|
|
# --non-interactive needs an explicit --provider + --model so it
|
|
# doesn't prompt; pick what matches your API key. Skip step 2 if
|
|
# you've already onboarded on this host.
|
|
#
|
|
# openclaw onboard --non-interactive \
|
|
# --provider openai \
|
|
# --model gpt-5
|
|
|
|
# 3. Wire the molecule MCP server. {{WORKSPACE_ID}} + {{PLATFORM_URL}}
|
|
# are stamped server-side; paste the auth token before running.
|
|
WORKSPACE_TOKEN="<paste from create response>"
|
|
MOLECULE_ORG_ID="<your org id>"
|
|
openclaw mcp set molecule "$(cat <<EOF
|
|
{
|
|
"command": "python3",
|
|
"args": ["-m", "molecule_runtime.a2a_mcp_server"],
|
|
"env": {
|
|
"WORKSPACE_ID": "{{WORKSPACE_ID}}",
|
|
"PLATFORM_URL": "{{PLATFORM_URL}}",
|
|
"MOLECULE_WORKSPACE_TOKEN": "$WORKSPACE_TOKEN",
|
|
"MOLECULE_ORG_ID": "$MOLECULE_ORG_ID"
|
|
}
|
|
}
|
|
EOF
|
|
)"
|
|
|
|
# 4. Start the openclaw gateway as a durable background process.
|
|
# A bare '&' dies when the terminal closes; nohup + log file keeps
|
|
# the gateway alive across logout. For systemd-managed hosts,
|
|
# register a unit instead.
|
|
nohup openclaw gateway --dev --port 18789 --bind loopback \
|
|
> ~/.openclaw/gateway.log 2>&1 &
|
|
disown
|
|
|
|
# 5. Run an agent turn — molecule tools are now available:
|
|
openclaw agent --message "list my peers"
|
|
`
|