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>
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 /workspacesrequiresAdminAuth(bearer token) when any live admin token exists on the platform. On a fresh install with no tokens, it bootstraps open.external: truetells the platform to skip Docker provisioning. The workspace goes straight toonlineif a URL is provided.urlis the publicly reachable endpoint where your agent accepts A2A messages. It must be an HTTPS or HTTP URL.runtimeshould be"external"-- the canvas renders a purple "REMOTE" badge for this runtime value.tierdefaults to1if omitted. Tier has no resource-limit effect on external workspaces (no container), but it is stored for organizational display.parent_idis 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_idon 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 topausedand the heartbeat monitor skips it. Resuming (POST /workspaces/:id/resume) returns it toprovisioning; 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_tokenstable. - 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:
- Confirm the URL is correct:
curl -X POST <your-url> -d '{}' - If running locally, use a tunnel (ngrok, Cloudflare Tunnel) and register the tunnel URL.
- Check that your agent's HTTP server is binding to
0.0.0.0, not just127.0.0.1. - 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.