Files
molecule-core/docs/api-protocol/platform-api.md
Molecule AI Dev Engineer B (MiniMax) 74bba182a5
CI / Python Lint & Test (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 6s
sop-checklist / review-refire (pull_request_target) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Successful in 2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 6s
reserved-path-review / reserved-path-review (pull_request_target) Failing after 8s
sop-checklist / na-declarations (pull_request) N/A: (none)
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
sop-checklist / all-items-acked (pull_request_target) Successful in 9s
gate-check-v3 / gate-check (pull_request_target) Failing after 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 18s
E2E Chat / detect-changes (pull_request) Successful in 28s
CI / Detect changes (pull_request) Successful in 32s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 30s
E2E Chat / E2E Chat (pull_request) Successful in 4s
CI / Canvas Deploy Status (pull_request) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 34s
Check migration collisions / Migration version collision check (pull_request) Successful in 48s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m17s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Failing after 1m58s
CI / Platform (Go) (pull_request) Successful in 2m30s
CI / all-required (pull_request) Successful in 5s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m36s
security-review / approved (pull_request_target) Approved via pull_request_review trigger
security-review / approved (pull_request_review) Successful in 8s
qa-review / approved (pull_request_target) Approved via pull_request_review trigger
reserved-path-review / reserved-path-review (pull_request_review) Successful in 10s
qa-review / approved (pull_request_review) Successful in 10s
audit-force-merge / audit (pull_request_target) Successful in 7s
sop-checklist / all-items-acked (pull_request) Compensated by status-reaper (non-required pull_request/pull_request_review governance shadow overridden by successful pull_request_target status; see .gitea/scripts/status-reaper.py)
fix(approvals#66): requester-initiated withdraw endpoint
Closes the long-standing gap where an agent had no way to retract an
approval it had raised but no longer needed. Issue #66 — the PM
re-dispatched this as INDEPENDENT of the RFC #2843 gate, and approved
the plan with the following guardrails (7600d2ed):

1. ADDITIVE + REVERSIBLE MIGRATION. The up migration widens
   approval_requests.status CHECK from {pending, approved, denied,
   escalated} to also include 'withdrawn'. The down migration deletes
   any 'withdrawn' rows AND narrows the CHECK back. Rollback-safe even
   if the endpoint has been exercised in the deploy window.

2. AUTHZ AGAINST CREATOR-WORKSPACE-ID, NOT PATH :id. The handler reads
   approval_requests.workspace_id (the row's creator) and compares it
   to the URL path's :id. The path :id is the GATE's workspace for
   cross-workspace approval gates (#2574, #2593) — using it as the
   authz anchor would reject legitimate creators when the gate and
   creator are different workspaces.

3. PENDING-ONLY STATE GUARD. The UPDATE has WHERE status='pending',
   and a 0-rows-affected result returns 409 Conflict (not 404) so the
   caller can distinguish 'row vanished' from 'row exists but already
   moved'. This is the same shape requests.Cancel uses for the
   analogous race.

4. DOCSTRING POINTER. The ListAll comment (which was reverted in
   bcabd207 because it inaccurately claimed a withdraw path existed)
   now points at the real endpoint instead.

NEW ENDPOINT: POST /workspaces/:id/approvals/:approvalId/withdraw
  - workspace-token auth (matches the existing approvals surface)
  - body: empty
  - 200 on success (status='withdrawn', decided_by='requester')
  - 403 if the caller's workspace != the row's creator workspace
  - 404 if the approval doesn't exist (or UUID is malformed)
  - 409 if the approval is no longer 'pending'
  - 500 on DB error
  - broadcasts APPROVAL_WITHDRAWN on the row's creator workspace_id
    (matches Decide's broadcast convention)

NEW FILES:
- migrations/20260614010000_approval_withdrawn_status.up.sql — widen CHECK
- migrations/20260614010000_approval_withdrawn_status.down.sql — narrow + purge

MODIFIED:
- internal/handlers/approvals.go — new Withdraw method + ListAll comment
- internal/handlers/approvals_test.go — 5 new tests:
  - TestApprovals_Withdraw_Success (happy path)
  - TestApprovals_Withdraw_NotPendingReturns409 (state guard)
  - TestApprovals_Withdraw_NotFound (404)
  - TestApprovals_Withdraw_CrossWorkspaceAuthzReject (the load-bearing
    cross-workspace authz test — verifies the authz check short-
    circuits before UPDATE; uses sqlmock.ExpectationsWereMet to
    confirm no UPDATE was issued)
  - TestApprovals_Withdraw_CrossWorkspaceGateOK (the #2574 / #2593
    scenario where the row's creator workspace matches the path's :id
    and withdraw proceeds normally)
- internal/router/router.go — wire the route (wsAuth)
- docs/api-reference.md, docs/api-protocol/platform-api.md — table entries

LOCAL VALIDATION:
- go test ./internal/handlers/  -> clean (26.4s, all 5 new + all existing)
- go test ./internal/provisioner/ -> clean (0.08s, no regressions from earlier)
- go vet ./...                 -> clean
- go build ./...               -> clean

Refs #66. PM-approved plan: 7600d2ed.
2026-06-14 12:43:50 +00:00

13 KiB
Raw Permalink Blame History

Platform API (Go Backend)

The Go backend is Molecule AI's control plane. It does not execute agent reasoning itself. It manages the infrastructure and coordination around workspaces.

Responsibilities

  • workspace lifecycle
  • registry and heartbeats
  • hierarchy-aware discovery
  • A2A proxying for browser-initiated calls
  • approvals and activity logs
  • memory APIs
  • secrets and global secrets
  • files, templates, bundles, terminal, and viewport state
  • WebSocket fanout to canvas clients and workspaces

Caller Identification

Workspace-scoped calls use the X-Workspace-ID header when the caller is another workspace. Browser/canvas calls do not send that header.

The platform uses the caller identity to enforce hierarchy-based access rules.

Breaking Changes

PR #701 — Input validation, route auth, UUID safety (2026-04-17)

Affects: PATCH /workspaces/:id, GET /workspaces/:id, DELETE /workspaces/:id, GET /templates, GET /org/templates

Change Before After
PATCH /workspaces/:id auth Open router — no token required for cosmetic fields wsAuth group — workspace bearer token required unconditionally
GET /templates auth No auth AdminAuth
GET /org/templates auth No auth AdminAuth
:id path parameter validation DB query with raw string; Postgres error on non-UUID uuid.Parse check before DB access — 400 "invalid workspace id" on non-UUID

Field validation added to POST /workspaces and PATCH /workspaces/:id:

Field Max length Additional constraints
name 255 chars No \n, \r, or YAML-special chars (`{}[]
role 1,000 chars No \n, \r, or YAML-special chars
model 100 chars No \n, \r
runtime 100 chars No \n, \r

Violations return 400 Bad Request with { "error": "<field> must be at most N characters" } or { "error": "<field> must not contain newline characters" }.

Migration steps for callers:

  1. Add Authorization: Bearer <workspace-token> to all PATCH /workspaces/:id requests.
  2. Add an admin bearer token to GET /templates and GET /org/templates requests.
  3. Ensure :id values in E2E scripts and automation are valid UUIDs. Update any test fixtures that use non-UUID IDs (see workspace-server/internal/handlers/*_test.go for updated examples).

Core Endpoints

Health and metrics

Method Path Description
GET /health Health check
GET /metrics Prometheus metrics

Workspaces

Method Path Description
POST /workspaces Create and provision a workspace
GET /workspaces List workspaces with inline canvas layout data
GET /workspaces/:id Get one workspace
PATCH /workspaces/:id Update workspace fields. Requires workspace bearer token (WorkspaceAuth). Validates name (≤255), role (≤1000), model/runtime (≤100 chars); name and role reject newlines and YAML-special chars (`{}[]
DELETE /workspaces/:id Remove workspace
POST /workspaces/:id/restart Restart workspace (reads runtime from container config.yaml before stop — detects runtime changes)
POST /workspaces/:id/pause Pause workspace
POST /workspaces/:id/resume Resume workspace
POST /workspaces/:id/a2a Proxy A2A request to the target workspace (synchronous, enforces hierarchy access control via X-Workspace-ID)
POST /workspaces/:id/delegate Async delegation — fire-and-forget, returns delegation_id
GET /workspaces/:id/delegations List delegation status (pending/completed/failed)

Async Delegation

POST /workspaces/:id/delegate sends a task to another workspace without blocking. The platform runs the A2A request in a background goroutine and returns immediately.

POST /workspaces/:id/delegate
{"target_id": "<workspace-uuid>", "task": "Review the PLAN.md"}

 202 {"delegation_id": "...", "status": "delegated", "target_id": "..."}

Poll GET /workspaces/:id/delegations to check results. Each entry includes delegation_id, status (pending/completed/failed), and response_preview. WebSocket events DELEGATION_COMPLETE and DELEGATION_FAILED are broadcast on completion.

This is the recommended way for agents to delegate work — it works for all runtimes (Claude Code, LangGraph, etc.) since it operates at the platform level.

Registry

Method Path Description Auth
POST /registry/register Workspace registration on startup. First register issues a per-workspace bearer token in the response body (auth_token); re-register is idempotent and omits the token.
POST /registry/heartbeat Liveness and task updates. Phase 30.1 — Authorization: Bearer <token> required if the workspace has any live token on file; legacy workspaces grandfathered (fail-open).
POST /registry/update-card Push Agent Card updates after runtime/skill changes. Phase 30.1 — same grandfather rule as /heartbeat.
GET /registry/discover/:id Resolve workspace URL for A2A calls. Phase 30.6 — caller sends X-Workspace-ID + own bearer token; fail-open on DB hiccup (hierarchy check is primary gate).
GET /registry/:id/peers List reachable peers. Phase 30.6 — same as /discover/:id.
POST /registry/check-access Validate reachability/access.

Why the auth callout matters: remote (Phase 30) agents authenticate themselves with the bearer token returned by POST /registry/register. Local containers are transparent to this during the lazy-bootstrap grace window — the provisioner threads the token in as an env var on first register. See docs/development/testing-e2e.md for how E2E scripts handle token capture. If you change these routes, update tests/e2e/test_api.sh in the same PR.

Activity and recall

Method Path Description
GET /workspaces/:id/activity List activity rows (?type=, ?source=canvas|agent, ?limit=)
POST /workspaces/:id/activity Report activity from a workspace
POST /workspaces/:id/notify Emit user-facing notifications/activity
GET /workspaces/:id/session-search Search recent activity + memory for recall

Memory

There are two distinct memory surfaces:

Scoped agent memory

Method Path Description
POST /workspaces/:id/memories Commit a LOCAL / TEAM / GLOBAL memory
GET /workspaces/:id/memories Search scoped memories
DELETE /workspaces/:id/memories/:memoryId Delete an owned memory

Key/value workspace memory

Method Path Description
GET /workspaces/:id/memory List key/value memory entries
GET /workspaces/:id/memory/:key Get one key/value entry
POST /workspaces/:id/memory Upsert a key/value entry with optional TTL
DELETE /workspaces/:id/memory/:key Delete a key/value entry

Secrets

Workspace secrets

Method Path Description
GET /workspaces/:id/secrets Return merged workspace + inherited global secret metadata
POST /workspaces/:id/secrets Upsert workspace secret
PUT /workspaces/:id/secrets Upsert workspace secret
DELETE /workspaces/:id/secrets/:key Delete workspace secret
GET /workspaces/:id/model Get workspace model override

Important detail: GET /workspaces/:id/secrets does not return values. It returns key metadata plus a scope field so the frontend can distinguish inherited globals from workspace overrides.

Global secrets

Method Path Description
GET /settings/secrets List global secret metadata
POST /settings/secrets Upsert global secret
PUT /settings/secrets Upsert global secret
DELETE /settings/secrets/:key Delete global secret

Backward-compatible admin aliases also exist under /admin/secrets.

Approvals

Method Path Description
GET /approvals/pending List pending approvals
POST /workspaces/:id/approvals Create approval request
GET /workspaces/:id/approvals List approvals for a workspace
POST /workspaces/:id/approvals/:approvalId/decide Approve or deny
POST /workspaces/:id/approvals/:approvalId/withdraw Requester pulls back a pending approval (issue #66). Authz is against the row's creator workspace, not the path :id, so it works correctly under cross-workspace approval gates (#2574 / #2593).

Team operations

Method Path Description
POST /workspaces/:id/expand Expand workspace into a team
POST /workspaces/:id/collapse Collapse team back down

Plugins

Method Path Description
GET /plugins List available plugins; accepts ?runtime=<name> to filter to compatible plugins
GET /plugins/sources List registered install-source schemes (e.g. {"schemes":["github","local"]})
GET /workspaces/:id/plugins List installed plugins (each includes supported_on_runtime: bool)
GET /workspaces/:id/plugins/available Plugins filtered to those compatible with the workspace runtime
GET /workspaces/:id/plugins/compatibility?runtime=X Preflight runtime change — which installed plugins would become inert
POST /workspaces/:id/plugins Install plugin {"source":"<scheme>://<spec>"} — e.g. local://ecc, github://owner/repo#v1.0. Auto-restarts workspace.
DELETE /workspaces/:id/plugins/:name Uninstall plugin — removes from container, auto-restarts

Plugins are installed per-workspace into /configs/plugins/<name>/. Sources are pluggable via schemes (local + github shipped; clawhub/oci/https planned). See docs/plugins/sources.md for the two-axis source/shape model.

Install safeguards bound the cost of a single install (env-tunable via PLUGIN_INSTALL_BODY_MAX_BYTES / PLUGIN_INSTALL_FETCH_TIMEOUT / PLUGIN_INSTALL_MAX_DIR_BYTES).

Files and templates

Method Path Description
GET /templates List available templates. Requires AdminAuth (PR #701).
GET /org/templates List available org templates. Requires AdminAuth (PR #701).
POST /templates/import Import an agent folder as a new template
GET /workspaces/:id/files List files under an allowed root
GET /workspaces/:id/files/*path Read a file
PUT /workspaces/:id/files/*path Write a file
PUT /workspaces/:id/files Replace workspace file set
DELETE /workspaces/:id/files/*path Delete a file

Query parameters for GET /workspaces/:id/files:

Param Default Description
root /configs Base path — one of /configs, /workspace, /home, /plugins
path "" Subdirectory relative to root (validated against path traversal)
depth 1 Max recursion depth (15). Use with path for lazy-loading subdirectories

Invalid depth or traversal paths return 400.

Terminal

Protocol Path Description
WS /workspaces/:id/terminal Terminal session into the running container

Bundles

Method Path Description
GET /bundles/export/:id Export workspace tree as a bundle
POST /bundles/import Import a bundle

Canvas viewport and events

Method Path Description
GET /canvas/viewport Get saved canvas pan/zoom
PUT /canvas/viewport Save canvas pan/zoom
GET /events List structure events
GET /events/:workspaceId List workspace-scoped events

WebSocket

Protocol Path Description
WS /ws Live events for canvas clients and workspaces

Canvas clients receive the global event stream. Workspaces connect with X-Workspace-ID and receive filtered events based on communication rules.

A2A Proxy Behavior

POST /workspaces/:id/a2a is more than a naive forwarder.

It currently:

  • enforces access control via CanCommunicate for agent-to-agent calls (workspace caller IDs from X-Workspace-ID); canvas requests, self-calls, and system callers (webhook:*, system:*, test:*) bypass
  • normalizes incoming JSON into JSON-RPC 2.0
  • injects messageId when missing
  • applies different timeout rules for browser-initiated vs workspace-initiated calls
  • logs the resulting A2A activity
  • broadcasts successful browser-initiated responses back to the canvas as A2A_RESPONSE
  • triggers restart flow when the target container is confirmed dead

That is why the chat UX no longer depends on polling as the primary response path.

Environment Variables

DATABASE_URL=postgres://dev:dev@postgres:5432/molecule?sslmode=prefer
REDIS_URL=redis://redis:6379
PORT=8080
SECRETS_ENCRYPTION_KEY=...
ACTIVITY_RETENTION_DAYS=7
ACTIVITY_CLEANUP_INTERVAL_HOURS=6
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
RATE_LIMIT=600