molecule-core/docs/guides/external-agent-registration.md
Hongming Wang 3892e4dee1 feat(platform): token management API + MCP setup + external agent guide
1. Token Management API (closes production gap):
   - GET /workspaces/:id/tokens — list tokens (prefix + metadata, never plaintext)
   - POST /workspaces/:id/tokens — create new token (plaintext returned once)
   - DELETE /workspaces/:id/tokens/:tokenId — revoke specific token
   - Behind WorkspaceAuth middleware (need existing token to manage tokens)
   - Tests skip gracefully when no DB available

2. MCP Server Setup:
   - Fix .mcp.json to use npx @molecule-ai/mcp-server (was referencing
     non-existent local ./mcp-server/dist/index.js)
   - Add comprehensive tool→API mapping doc (87 tools across 15 categories)

3. External Agent Registration Guide:
   - Step-by-step: create workspace, register, heartbeat, A2A messaging
   - Python (Flask) and Node.js (Express) complete working examples
   - Communication rules, lifecycle, security, troubleshooting

4. Token Management Guide:
   - Bootstrap flow, rotation procedure, security properties

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:37:42 -07:00

22 KiB

External Agent Registration Guide

Overview

An external agent (also called a remote agent) is any AI agent that runs outside the Molecule AI platform's Docker network but participates in the canvas, communicates with other agents via the A2A protocol, and is managed as a first-class workspace.

Use cases for external agents:

  • Existing infrastructure -- you already run an agent on your own servers and want it to join a Molecule org without re-deploying inside Docker.
  • Different cloud / region -- the agent runs on AWS, GCP, Azure, or another provider while the platform runs elsewhere.
  • Edge devices -- agents running on-premises or on edge hardware that cannot be containerized by the platform.
  • Third-party services -- SaaS bots or webhook-driven services that expose an A2A-compatible HTTP endpoint.
  • Development / debugging -- run an agent locally on your laptop while pointing it at a shared platform instance.

External workspaces behave identically to platform-provisioned workspaces in every way except two: the platform does not start or stop a Docker container for them, and liveness is tracked exclusively through the heartbeat TTL (the Docker health sweep is skipped).


Prerequisites

Requirement Details
Running Molecule AI platform Default http://localhost:8080. Set NEXT_PUBLIC_PLATFORM_URL in canvas accordingly.
Publicly reachable HTTP endpoint Your agent must accept POST requests for incoming A2A messages. If you are behind NAT, use a tunnel (ngrok, Cloudflare Tunnel, etc.).
Bearer token storage The platform issues a 256-bit auth token on first registration. You must persist it -- it is shown only once and cannot be recovered.

Step-by-Step Registration

1. Create an External Workspace

curl -X POST http://localhost:8080/workspaces \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <admin-token>" \
  -d '{
    "name": "My External Agent",
    "role": "researcher",
    "runtime": "external",
    "external": true,
    "url": "https://my-agent.example.com",
    "tier": 2,
    "parent_id": null
  }'

Response:

{
  "id": "a1b2c3d4-...",
  "status": "online",
  "external": true
}

Notes:

  • POST /workspaces requires AdminAuth (bearer token) when any live admin token exists on the platform. On a fresh install with no tokens, it bootstraps open.
  • external: true tells the platform to skip Docker provisioning. The workspace goes straight to online if a URL is provided.
  • url is the publicly reachable endpoint where your agent accepts A2A messages. It must be an HTTPS or HTTP URL.
  • runtime should be "external" -- the canvas renders a purple "REMOTE" badge for this runtime value.
  • tier defaults to 1 if omitted. Tier has no resource-limit effect on external workspaces (no container), but it is stored for organizational display.
  • parent_id is optional. Set it to nest this workspace under an existing team/parent workspace.

Save the returned id -- you will need it for every subsequent call.

2. Register with the Platform

curl -X POST http://localhost:8080/registry/register \
  -H "Content-Type: application/json" \
  -d '{
    "id": "<workspace-id-from-step-1>",
    "url": "https://my-agent.example.com",
    "agent_card": {
      "name": "My External Agent",
      "description": "Handles research tasks and summarization",
      "skills": ["research", "summarization", "analysis"],
      "runtime": "external"
    }
  }'

Response (first registration):

{
  "status": "registered",
  "auth_token": "mol_abc123...very-long-token"
}

Critical: The auth_token field is present only on first registration. It is never returned again. Store it securely (environment variable, secrets manager, etc.). All subsequent authenticated calls require this token.

On re-registration (e.g., after your agent restarts), the response contains only {"status": "registered"} -- the original token remains valid.

3. Start the Heartbeat Loop

Your agent must send a heartbeat every 30 seconds to stay online. If the platform receives no heartbeat for 60 seconds, the workspace transitions to offline.

curl -X POST http://localhost:8080/registry/heartbeat \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <auth_token>" \
  -d '{
    "workspace_id": "<workspace-id>",
    "error_rate": 0.0,
    "active_tasks": 0,
    "current_task": "",
    "uptime_seconds": 3600,
    "sample_error": ""
  }'

Response:

{ "status": "ok" }

Heartbeat fields:

Field Type Description
workspace_id string Required. Your workspace ID.
error_rate float 0.0 -- 1.0. If > 0.5, workspace enters degraded status on the canvas.
active_tasks int Number of tasks currently running. Displayed in the canvas node.
current_task string Human-readable description of current work. Shown in the workspace detail panel.
uptime_seconds int Seconds since your agent started.
sample_error string Most recent error message, if any. Visible in monitoring.

4. Handle Incoming A2A Messages

Your agent must accept POST requests at the URL you registered. The platform (and other agents) send messages in A2A JSON-RPC format:

{
  "jsonrpc": "2.0",
  "method": "message/send",
  "params": {
    "message": {
      "role": "user",
      "parts": [
        { "type": "text", "text": "Research the latest trends in AI safety" }
      ]
    },
    "metadata": {
      "source": "agent",
      "history": []
    }
  },
  "id": "req-abc-123"
}

Your endpoint must return a JSON-RPC response:

{
  "jsonrpc": "2.0",
  "result": {
    "message": {
      "role": "agent",
      "parts": [
        { "type": "text", "text": "Here are the latest AI safety trends..." }
      ]
    }
  },
  "id": "req-abc-123"
}

For errors, return a JSON-RPC error object:

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32603,
    "message": "Internal error: model rate limited"
  },
  "id": "req-abc-123"
}

5. Send Messages to Other Agents

Use the A2A proxy to communicate with any workspace your agent is allowed to reach:

curl -X POST http://localhost:8080/workspaces/<target-workspace-id>/a2a \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <auth_token>" \
  -H "X-Workspace-ID: <your-workspace-id>" \
  -d '{
    "jsonrpc": "2.0",
    "method": "message/send",
    "params": {
      "message": {
        "role": "user",
        "parts": [
          { "type": "text", "text": "Can you help with this task?" }
        ]
      },
      "metadata": {}
    },
    "id": "req-456"
  }'

Both headers are required:

  • Authorization: Bearer <token> -- your workspace auth token.
  • X-Workspace-ID: <your-id> -- identifies which workspace is making the call. The platform uses this to enforce communication rules.

6. Discover Peers

Find out which other workspaces your agent can communicate with:

# Get your workspace's own info
curl http://localhost:8080/registry/discover/<your-workspace-id> \
  -H "Authorization: Bearer <auth_token>" \
  -H "X-Workspace-ID: <your-workspace-id>"

# List peers (siblings + parent + children)
curl http://localhost:8080/registry/<your-workspace-id>/peers \
  -H "Authorization: Bearer <auth_token>" \
  -H "X-Workspace-ID: <your-workspace-id>"

The peers endpoint returns workspaces that your agent is allowed to communicate with, based on the hierarchy rules below.


Communication Rules

The platform enforces strict hierarchy-based access control via CanCommunicate(callerID, targetID):

Relationship Allowed
Same workspace (self-call) Yes
Siblings (same parent_id) Yes
Root-level siblings (both parent_id is NULL) Yes
Parent to child Yes
Child to parent Yes
Everything else Denied

Canvas requests (no X-Workspace-ID header) and system callers (webhook:*, system:*, test:* prefixes) bypass this check.


Canvas Appearance

External workspaces appear on the canvas with a purple REMOTE badge instead of the usual runtime pill (e.g., "LANGGRAPH", "CLAUDE-CODE").

They support all standard canvas features:

  • Drag-and-drop positioning (persisted via PATCH /workspaces/:id)
  • Nesting into team nodes (set parent_id on create or move via API)
  • Real-time status updates -- heartbeat data (active tasks, current task, error rate) is broadcast to canvas clients via WebSocket
  • Chat panel -- "My Chat" tab sends A2A messages to the agent; "Agent Comms" tab shows inter-agent traffic
  • Config and secrets management via the detail panel

Example: Python Implementation

A minimal external agent using requests and flask:

"""
Minimal Molecule AI external agent.

pip install flask requests
"""

import os
import sys
import time
import threading
import requests
from flask import Flask, request, jsonify

PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
AGENT_URL = os.environ.get("AGENT_URL")  # e.g. "https://my-agent.ngrok.io"
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")  # for POST /workspaces
AGENT_NAME = os.environ.get("AGENT_NAME", "Python External Agent")

app = Flask(__name__)

# --- State ---
workspace_id = None
auth_token = None
start_time = time.time()


# --- Step 4: Handle incoming A2A messages ---
@app.route("/", methods=["POST"])
def handle_a2a():
    payload = request.get_json(force=True)
    method = payload.get("method", "")
    req_id = payload.get("id", "unknown")

    if method == "message/send":
        text = ""
        parts = (
            payload.get("params", {}).get("message", {}).get("parts", [])
        )
        for part in parts:
            if part.get("type") == "text":
                text += part.get("text", "")

        # --- Your agent logic here ---
        reply = f"Received: {text}. Processing complete."

        return jsonify({
            "jsonrpc": "2.0",
            "result": {
                "message": {
                    "role": "agent",
                    "parts": [{"type": "text", "text": reply}],
                }
            },
            "id": req_id,
        })

    return jsonify({
        "jsonrpc": "2.0",
        "error": {"code": -32601, "message": f"Unknown method: {method}"},
        "id": req_id,
    })


# --- Step 3: Heartbeat loop ---
def heartbeat_loop():
    while True:
        try:
            requests.post(
                f"{PLATFORM_URL}/registry/heartbeat",
                json={
                    "workspace_id": workspace_id,
                    "error_rate": 0.0,
                    "active_tasks": 0,
                    "current_task": "",
                    "uptime_seconds": int(time.time() - start_time),
                },
                headers={"Authorization": f"Bearer {auth_token}"},
                timeout=10,
            )
        except Exception as e:
            print(f"Heartbeat failed: {e}", file=sys.stderr)
        time.sleep(30)


def register():
    global workspace_id, auth_token

    if not AGENT_URL:
        print("ERROR: Set AGENT_URL to your publicly reachable endpoint", file=sys.stderr)
        sys.exit(1)

    # Step 1: Create external workspace
    headers = {"Content-Type": "application/json"}
    if ADMIN_TOKEN:
        headers["Authorization"] = f"Bearer {ADMIN_TOKEN}"

    resp = requests.post(
        f"{PLATFORM_URL}/workspaces",
        json={
            "name": AGENT_NAME,
            "runtime": "external",
            "external": True,
            "url": AGENT_URL,
            "tier": 2,
        },
        headers=headers,
        timeout=10,
    )
    resp.raise_for_status()
    workspace_id = resp.json()["id"]
    print(f"Created workspace: {workspace_id}")

    # Step 2: Register with the platform
    resp = requests.post(
        f"{PLATFORM_URL}/registry/register",
        json={
            "id": workspace_id,
            "url": AGENT_URL,
            "agent_card": {
                "name": AGENT_NAME,
                "description": "A minimal Python external agent",
                "skills": ["echo"],
                "runtime": "external",
            },
        },
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()
    auth_token = data.get("auth_token")
    if auth_token:
        print(f"Auth token received (save this!): {auth_token[:12]}...")
    else:
        print("No new token issued (re-registration). Use your saved token.")
        auth_token = os.environ.get("AUTH_TOKEN")
        if not auth_token:
            print("ERROR: Set AUTH_TOKEN for re-registration", file=sys.stderr)
            sys.exit(1)

    # Start heartbeat
    t = threading.Thread(target=heartbeat_loop, daemon=True)
    t.start()
    print("Heartbeat loop started (every 30s)")


if __name__ == "__main__":
    register()
    print(f"Listening on {AGENT_URL}")
    app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))

Run it:

export PLATFORM_URL=http://localhost:8080
export AGENT_URL=https://my-agent.ngrok.io
export ADMIN_TOKEN=your-admin-bearer-token
python external_agent.py

Example: Node.js Implementation

A minimal external agent using the built-in fetch and express:

/**
 * Minimal Molecule AI external agent.
 *
 * npm install express
 * Node.js 18+ required (native fetch).
 */

const express = require("express");

const PLATFORM_URL = process.env.PLATFORM_URL || "http://localhost:8080";
const AGENT_URL = process.env.AGENT_URL; // e.g. "https://my-agent.ngrok.io"
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "";
const AGENT_NAME = process.env.AGENT_NAME || "Node External Agent";
const PORT = parseInt(process.env.PORT || "5000", 10);

let workspaceId = null;
let authToken = null;
const startTime = Date.now();

// --- Step 4: Handle incoming A2A messages ---
const app = express();
app.use(express.json());

app.post("/", (req, res) => {
  const { method, id: reqId, params } = req.body;

  if (method === "message/send") {
    const parts = params?.message?.parts || [];
    const text = parts
      .filter((p) => p.type === "text")
      .map((p) => p.text)
      .join("");

    // --- Your agent logic here ---
    const reply = `Received: ${text}. Processing complete.`;

    return res.json({
      jsonrpc: "2.0",
      result: {
        message: {
          role: "agent",
          parts: [{ type: "text", text: reply }],
        },
      },
      id: reqId,
    });
  }

  res.json({
    jsonrpc: "2.0",
    error: { code: -32601, message: `Unknown method: ${method}` },
    id: reqId,
  });
});

// --- Step 3: Heartbeat loop ---
function startHeartbeat() {
  setInterval(async () => {
    try {
      await fetch(`${PLATFORM_URL}/registry/heartbeat`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${authToken}`,
        },
        body: JSON.stringify({
          workspace_id: workspaceId,
          error_rate: 0.0,
          active_tasks: 0,
          current_task: "",
          uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
        }),
      });
    } catch (err) {
      console.error("Heartbeat failed:", err.message);
    }
  }, 30_000);
}

// --- Steps 1 & 2: Create + register ---
async function register() {
  if (!AGENT_URL) {
    console.error("ERROR: Set AGENT_URL to your publicly reachable endpoint");
    process.exit(1);
  }

  // Step 1: Create external workspace
  const headers = { "Content-Type": "application/json" };
  if (ADMIN_TOKEN) headers["Authorization"] = `Bearer ${ADMIN_TOKEN}`;

  const createResp = await fetch(`${PLATFORM_URL}/workspaces`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      name: AGENT_NAME,
      runtime: "external",
      external: true,
      url: AGENT_URL,
      tier: 2,
    }),
  });
  if (!createResp.ok) throw new Error(`Create failed: ${createResp.status}`);
  const createData = await createResp.json();
  workspaceId = createData.id;
  console.log(`Created workspace: ${workspaceId}`);

  // Step 2: Register
  const regResp = await fetch(`${PLATFORM_URL}/registry/register`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      id: workspaceId,
      url: AGENT_URL,
      agent_card: {
        name: AGENT_NAME,
        description: "A minimal Node.js external agent",
        skills: ["echo"],
        runtime: "external",
      },
    }),
  });
  if (!regResp.ok) throw new Error(`Register failed: ${regResp.status}`);
  const regData = await regResp.json();

  authToken = regData.auth_token;
  if (authToken) {
    console.log(`Auth token received (save this!): ${authToken.slice(0, 12)}...`);
  } else {
    console.log("No new token issued (re-registration). Using saved token.");
    authToken = process.env.AUTH_TOKEN;
    if (!authToken) {
      console.error("ERROR: Set AUTH_TOKEN for re-registration");
      process.exit(1);
    }
  }

  startHeartbeat();
  console.log("Heartbeat loop started (every 30s)");
}

register().then(() => {
  app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
});

Run it:

export PLATFORM_URL=http://localhost:8080
export AGENT_URL=https://my-agent.ngrok.io
export ADMIN_TOKEN=your-admin-bearer-token
node external_agent.js

Lifecycle

External workspaces follow a simplified version of the standard lifecycle:

provisioning --> online (on create with URL, or on register)
                   |
                   v
              degraded (error_rate > 0.5 in heartbeat)
                   |
                   v
               online (error_rate recovers)
                   |
                   v
              offline (heartbeat TTL expires after 60s)
                   |
                   v
              removed (DELETE /workspaces/:id)

Key differences from platform-managed workspaces:

  • No Docker health sweep -- external workspaces are invisible to the Docker API. Only the Redis heartbeat TTL determines liveness.
  • No auto-restart -- when an external workspace goes offline, the platform does not attempt to restart it. Your agent is responsible for re-registering and resuming heartbeats.
  • Pause/resume -- pausing an external workspace (POST /workspaces/:id/pause) sets status to paused and the heartbeat monitor skips it. Resuming (POST /workspaces/:id/resume) returns it to provisioning; your agent must re-register to go back online.

Security

Auth Token

  • Tokens are 256-bit cryptographically random values.
  • The raw token is returned once at first registration. The platform stores only the SHA-256 hash in the workspace_auth_tokens table.
  • Lost tokens cannot be recovered. If you lose your token, delete the workspace and create a new one (or wait for a future token-reset API).
  • Tokens are automatically revoked when a workspace is deleted.

Required Headers

Endpoint Required Headers
POST /registry/heartbeat Authorization: Bearer <token>
POST /registry/update-card Authorization: Bearer <token>
POST /workspaces/:target/a2a Authorization: Bearer <token>, X-Workspace-ID: <your-id>
GET /registry/discover/:id Authorization: Bearer <token>, X-Workspace-ID: <your-id>
GET /registry/:id/peers Authorization: Bearer <token>, X-Workspace-ID: <your-id>

Legacy / Bootstrap Behavior

Workspaces that registered before the token system existed (Phase 30.1) are grandfathered -- their requests pass through without a token until their next /registry/register call issues one. After that, the token is enforced on every subsequent call.


Updating Your Agent Card

If your agent's capabilities change, update the card without re-registering:

curl -X POST http://localhost:8080/registry/update-card \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <auth_token>" \
  -d '{
    "workspace_id": "<workspace-id>",
    "agent_card": {
      "name": "My External Agent v2",
      "description": "Now with summarization and translation",
      "skills": ["research", "summarization", "translation"],
      "runtime": "external"
    }
  }'

Troubleshooting

Workspace shows "offline" on the canvas

Cause: Heartbeat has not been received in the last 60 seconds.

Fix: Verify your heartbeat loop is running, sending to the correct workspace_id, and including Authorization: Bearer <token>. Check network connectivity between your agent and the platform. Inspect platform logs for 401 errors.

401 Unauthorized on heartbeat

Cause: Missing or invalid auth token.

Fix: Ensure you are sending the exact token returned at first registration. Tokens are case-sensitive. If you lost the token, you must delete the workspace and re-create it.

Cannot send A2A messages (403 or communication denied)

Cause: The CanCommunicate check failed -- your workspace is not a sibling, parent, or child of the target.

Fix: Check the hierarchy. Use GET /registry/<your-id>/peers to see which workspaces you can reach. If needed, move your workspace under the correct parent via PATCH /workspaces/:id with {"parent_id": "..."}.

Agent card not showing on canvas

Cause: Registration was not called, or the agent_card JSON was malformed.

Fix: Call POST /registry/register again with a valid agent_card object. The name, description, and skills fields are used for display on the canvas.

Messages not reaching your agent

Cause: The URL you registered is not reachable from the platform.

Fix:

  1. Confirm the URL is correct: curl -X POST <your-url> -d '{}'
  2. If running locally, use a tunnel (ngrok, Cloudflare Tunnel) and register the tunnel URL.
  3. Check that your agent's HTTP server is binding to 0.0.0.0, not just 127.0.0.1.
  4. Verify firewall rules allow inbound traffic on your agent's port.

Workspace stuck in "provisioning"

Cause: The external: true flag was not set on create, so the platform tried to provision a Docker container.

Fix: Delete the workspace and re-create it with "external": true in the payload.

Re-registration does not return a token

Expected behavior. The token is issued only on first registration. On re-registration, use the token you saved from the first time. If you need to start fresh, delete the workspace and create a new one.

Heartbeat succeeds but status stays "degraded"

Cause: Your heartbeat is reporting error_rate > 0.5.

Fix: Lower the error_rate field in your heartbeat payload. The workspace recovers to online automatically once the rate drops below 0.5.