LemonControlPlane

September 2, 2026 ยท View on GitHub

HTTP and WebSocket control plane API server for the Lemon agent system. Provides a frame-based JSON protocol over WebSocket for real-time bidirectional communication.

Review-first source learning

learn.review (read scope) resolves bounded context references and returns a content-free proposal with provenance hashes, memory/draft actions, audit rule codes, conflicts, and an exact confirmation digest. learn.confirm (admin scope) recomputes the source and destination state, then writes through the existing durable-memory and synthesis-draft stores only when that digest still matches. Neither response includes source text, prompts, paths, URLs, or secrets.

Overview

LemonControlPlane is the external interface through which clients (terminal UI, web dashboards, mobile apps, browser extensions) interact with the Lemon agent runtime. It exposes 100+ JSON-RPC-style methods over WebSocket for submitting agent runs, managing sessions, configuring the system, scheduling cron jobs, pairing nodes/devices, and streaming real-time events.

It is also the controller transport for named coding execution nodes. Paired nodes authenticate as the restricted node role, register one live connection under a durable unique name, and receive targeted native coding_agent.run invocations through LemonCore.NodeRegistry.

The server runs on Bandit with Plug routing, and uses WebSockAdapter for WebSocket upgrades.

Architecture

+-----------------+     HTTP/WebSocket      +------------------+
|  Clients (TUI)  |<----------------------->|  Bandit Server   |
|  Web, Mobile    |      Port 4040          |  (Router plug)   |
+-----------------+                         +--------+---------+
                                                     |
                    +--------------------------------+---------------------------+
                    |                 |               |                          |
             +------v------+  +------v------+  +-----v----------+  +-----------v---------+
             |  /healthz   |  |     /ws        |  |  404 fallback       |
             |  (GET JSON) |  |  (WebSocket)   |  |                     |
             +-------------+  +------+---------+  +---------------------+
                                                      |
                      +-------------------------------+-------------------------------+
                      |                               |                               |
               +------v------+              +---------v---------+           +---------v---------+
               |   Connect   |              |  Request Frame    |           |   Event Bridge    |
               |  Handshake  |              |   Dispatch        |           |  (Bus -> WS)      |
               +-------------+              +--------+----------+           +-------------------+
                                                     |
                                             +-------v--------+
                                             | Schema Validate|
                                             | (Schemas mod)  |
                                             +-------+--------+
                                                     |
                                             +-------v--------+
                                             | Method Registry|
                                             | (ETS lookup)   |
                                             +-------+--------+
                                                     |
                                             +-------v--------+
                                             |  Auth Check    |
                                             |  (scopes)      |
                                             +-------+--------+
                                                     |
                                             +-------v--------+
                                             | Method Handler |
                                             | (100+ methods) |
                                             +----------------+

Request Lifecycle

  1. A JSON text frame arrives on the WebSocket connection.
  2. Protocol.Frames.parse/1 decodes and validates the frame structure.
  3. If the connection has not yet completed the handshake, only connect is allowed; all other methods return HANDSHAKE_REQUIRED.
  4. For connect, Auth.Authorize.from_params/2 establishes the auth context from the credential and actual socket peer, and a hello-ok frame is returned.
  5. For all other methods, Protocol.Schemas.validate/2 checks required/optional parameter types.
  6. Methods.Registry.dispatch/3 looks up the handler module in the ETS table.
  7. Auth.Authorize.authorize/3 verifies the connection has the required scopes for the method.
  8. The handler's handle/2 callback executes and returns {:ok, payload} or {:error, ...}.
  9. Protocol.Frames.encode_response/2 serializes the result to JSON and pushes it to the client.

Supervision Tree

LemonControlPlane.Supervisor (one_for_one)
  |-- Methods.Registry          (GenServer, ETS-backed method dispatch)
  |-- Presence                  (GenServer, ETS-backed client tracking)
  |-- EventBridge.FanoutSupervisor  (Task.Supervisor for broadcast dispatch)
  |-- EventBridge              (GenServer, Bus -> WebSocket event fanout)
  |-- ConnectionSupervisor     (DynamicSupervisor for WS connections)
  |-- ConnectionRegistry       (Registry for connection process lookup)
  |-- Bandit                   (HTTP server, Plug router)
  |-- A2A.TaskSupervisor       (A2A runs outlive HTTP/SSE clients)
  |-- A2A.RateLimiter          (per-authenticated-peer request bounds, when enabled)
  |-- A2A.Server               (optional A2A v1.0 listener, default port 9901)

HTTP Endpoints

MethodPathDescription
GET/healthzHealth check, returns {"ok": true}
GET/wsWebSocket upgrade endpoint
GET/v1/healthOpenAI-compatible preview health metadata
GET/v1/capabilitiesOpenAI-compatible preview capability metadata
GET/v1/modelsOpenAI-compatible model list shape backed by Lemon model metadata, including supportsVision
POST/v1/chat/completionsPreview adapter that submits a Lemon run and returns queued chat.completion metadata by default, assistant text with wait: true, or SSE chunks with stream: true; accepts redacted URL/file-id image metadata, data URL image pass-through, and opt-in allowlisted HTTPS image URL fetch, and rejects runtime image bytes for known text-only models before submission
POST/v1/responsesPreview adapter that submits a Lemon run and returns a queued response object by default, output text with wait: true, or Responses-style SSE events with stream: true; accepts redacted URL/file-id image metadata, data URL image pass-through, opt-in allowlisted HTTPS image URL fetch, and previous_response_id for session continuation, and rejects runtime image bytes for known text-only models before submission
GET/v1/responses/:response_idPreview stored response retrieval for resp_<run_id> over the Lemon run store
GET/v1/runs/:run_idPreview redacted run status metadata
POST/v1/runs/:run_id/cancelPreview run cancellation dispatch through the Lemon router
POST/acpPreview Agent Client Protocol JSON-RPC bridge for initialize, session lifecycle, prompt, cancel, and close over Lemon router runs

When [gateway] enable_a2a = true, a separate listener serves the A2A v1.0 Agent Card, JSON-RPC, and SSE task surface on port 9901 by default. It is kept off the operator control-plane port so peer credentials and exposure policy do not become control-plane authorization. See docs/user-guide/a2a-peers.md. Accepted cancellation is a terminal, first-writer-wins task transition: a delayed submission or run completion cannot revive a canceled task, add an agent reply, or increment its context turn count. Router/store failures are mapped to fixed bounded A2A error messages; internal reasons, paths, and secret values never cross the peer wire. If submission acknowledgement is lost, the runner never retries: it reconciles the caller-generated run ID for one bounded wait and otherwise leaves the task working with a fixed reconciliation status. Later task reads reconcile durable completion through that same run ID. An ambiguous cancellation acknowledgement likewise never invents a canceled task.

profile.chat also generates its run ID before router submission. A definite acknowledgement returns the ordinary success projection; an ambiguous acknowledgement returns a bounded UNAVAILABLE error containing that run ID, the stable profile/session identifiers, and retrySafe: false. Raw router reasons are neither logged nor returned by the profile methods.

The /v1 generation endpoints are compatibility adapters, not a separate runtime path. They submit through the Lemon router and return lemon.runId by default; clients can use /ws events, call agent.wait, or set wait: true with optional timeout_ms / timeoutMs to synchronously wait through the same agent.wait path. With stream: true, the HTTP process subscribes to the run topic and returns text/event-stream chunks from Lemon run bus events, including redacted tool-progress events for :engine_action updates. Run status responses intentionally omit raw run events and assistant answer text. Stored Responses use resp_<run_id> ids, and previous_response_id reuses the prior response session key by default. Image input has a split boundary: HTTP(S) URLs and file ids are hashed/redacted into run metadata and bounded prompt placeholders by default, while base64 data URLs are validated, size/count-limited, redacted from prompts and metadata, and passed as runtime-only image blocks to native Lemon providers. HTTPS image URL fetch is available only when :openai_compat_image_url_fetch or LEMON_OPENAI_COMPAT_IMAGE_URL_FETCH=true is set and the host is present in :openai_compat_image_url_allowed_hosts, LEMON_OPENAI_COMPAT_IMAGE_URL_ALLOWED_HOSTS, or LEMON_OPENAI_COMPAT_IMAGE_HOST_ALLOWLIST; fetched images use the same runtime-only image path. Raw image references are omitted from HTTP responses and status payloads. Set :openai_compat_api_token, LEMON_OPENAI_COMPAT_API_TOKEN, or LEMON_OPENAI_COMPAT_TOKEN to require Authorization: Bearer <token> or x-api-key: <token> on /v1.

The /acp endpoint is also an adapter over the existing router/run graph. It supports JSON-RPC initialize, session/new, session/resume, session/list, session/prompt, session/cancel, and session/close. session/prompt accepts ACP text and resource_link blocks, submits a supervised Lemon run, and either waits through agent.wait or returns queued metadata when _meta.lemon.wait is false. The same handler is available to spawned line-oriented stdio clients through scripts/lemon_acp_stdio.exs, using ACP's newline-delimited JSON stream shape. Waiting stdio prompts emit intermediate session/update notification lines for Lemon text deltas and redacted tool progress before the final response. The stdio bridge can round-trip session/request_permission, fs/read_text_file, fs/write_text_file, fs/delete_file, and fs/rename_file client requests while the prompt waits, and carries only safe filesystem capability booleans into Lemon run metadata. The capability response intentionally leaves image, audio, embedded-resource, MCP HTTP, and MCP SSE support disabled until those paths have safe artifact and streaming contracts. Set :acp_api_token or LEMON_ACP_API_TOKEN to require bearer or x-api-key auth for HTTP.

WebSocket Protocol

Frame Types

Request (client to server):

{
  "type": "req",
  "id": "<uuid>",
  "method": "<method_name>",
  "params": {}
}

Response (server to client):

{
  "type": "res",
  "id": "<uuid>",
  "ok": true,
  "payload": {}
}
{
  "type": "res",
  "id": "<uuid>",
  "ok": false,
  "error": {"code": "NOT_FOUND", "message": "..."}
}

Event (server to client, asynchronous):

{
  "type": "event",
  "event": "<event_name>",
  "seq": 1,
  "payload": {},
  "stateVersion": {"presence": 2, "health": 0, "cron": 1}
}

Method handlers run inline by default. A handler that can wait for human input or slow external work may declare dispatch_mode/0 as :async; the server then keeps the correlated request open while the WebSocket process continues to deliver events and answer other requests. Approval-gated skills.install and skills.update use this mode so their own approval events and client liveness probes cannot deadlock behind the pending mutation. Async handlers must use the conn_pid in their context when they need the authenticated connection; they must not assume their own self() is the socket process.

Hello-OK (handshake completion, replaces res for connect):

{
  "type": "hello-ok",
  "protocol": 1,
  "server": {
    "version": "0.1.0",
    "commit": "abc123",
    "host": "hostname",
    "connId": "<uuid>"
  },
  "features": {
    "methods": ["health", "status", "agent", "..."],
    "events": ["agent", "chat", "presence", "..."]
  },
  "snapshot": {
    "presence": {},
    "health": {"ok": true}
  },
  "policy": {
    "maxPayload": 1048576,
    "maxBufferedBytes": 8388608,
    "tickIntervalMs": 1000
  },
  "auth": {
    "role": "operator",
    "scopes": ["admin", "read", "write", "approvals", "pairing"]
  }
}

Connection Handshake

  1. Client connects to ws://host:4040/ws.
  2. Client sends a connect request:
    {
      "type": "req",
      "id": "uuid",
      "method": "connect",
      "params": {
        "role": "operator",
        "scopes": ["operator.read", "operator.write"],
        "auth": {"token": "optional-session-token"},
        "client": {"id": "my-client"}
      }
    }
    
  3. Server responds with a hello-ok frame containing available methods, events, initial snapshot, and the resolved auth context.
  4. All subsequent requests use the established auth context.
  5. Sending connect again on the same connection returns ALREADY_CONNECTED.

Error Codes

CodeAtomDescription
INVALID_REQUEST:invalid_requestMalformed request or missing required fields
INVALID_PARAMS:invalid_paramsInvalid method parameters
METHOD_NOT_FOUND:method_not_foundUnknown method name
UNAUTHORIZED:unauthorizedAuthentication required or invalid token
FORBIDDEN:forbiddenInsufficient permissions
NOT_FOUND:not_foundRequested resource not found
CONFLICT:conflictResource state conflict
RATE_LIMITED:rate_limitedToo many requests
TIMEOUT:timeoutOperation timed out
INTERNAL_ERROR:internal_errorServer error
NOT_IMPLEMENTED:not_implementedMethod not yet implemented
HANDSHAKE_REQUIRED:handshake_requiredMust complete connect handshake first
ALREADY_CONNECTED:already_connectedConnection already established
UNAVAILABLE:unavailableResource temporarily unavailable

Authentication and Authorization

Roles and Scopes

RoleScopesHow Established
operatoradmin, read, write, approvals, pairingLEMON_CONTROL_PLANE_OPERATOR_TOKEN; legacy tokenless direct-loopback access requires an explicit compatibility opt-in
nodeinvoke, eventToken from connect.challenge after node pairing
devicecontrolToken from connect.challenge after device pairing

Scope strings used in connect params: operator.admin, operator.read, operator.write, operator.approvals, operator.pairing, node.invoke, node.event, device.control.

Set LEMON_CONTROL_PLANE_OPERATOR_TOKEN to a high-entropy value for normal operator access. Operator clients send that value as auth.token in the connect request, never in the WebSocket URL. Comparison uses fixed-length SHA-256 digests and Plug.Crypto.secure_compare/2; the credential is not retained in the connection auth context or returned in status data.

Tokenless WebSocket operator access is disabled by default, including for loopback peers. Set LEMON_CONTROL_PLANE_ALLOW_UNAUTHENTICATED_LOOPBACK=true only to restore legacy compatibility for direct loopback clients. Non-loopback peers always fail closed, and this compatibility switch must never be enabled for a reverse-proxied control plane because the proxy may itself be the socket's loopback peer. Unknown node/device session identity types are rejected and cannot fall back to an operator role.

For a named coding execution node, use the same credential only for initial pairing:

export LEMON_CONTROL_PLANE_OPERATOR_TOKEN="$(openssl rand -hex 32)" # controller
export LEMON_NODE_OPERATOR_TOKEN="$LEMON_CONTROL_PLANE_OPERATOR_TOKEN" # joining host
./bin/lemon node join --name worker-name --controller wss://controller.example/ws --pair

Prefer LEMON_NODE_OPERATOR_TOKEN to --operator-token so the credential does not enter shell history. After pairing, the worker stores and uses its separate node session token; it does not retain the operator credential.

Token-Based Authentication (Nodes/Devices)

  1. Node calls node.pair.request with nodeType and nodeName.
  2. Operator approves via node.pair.approve, which returns a one-time challenge.
  3. Node calls connect.challenge with that challenge and receives a session token (TTL: seven days for this pairing flow).
  4. Node uses {"auth": {"token": "..."}} in future connect calls.

node.pair.verify is the public pairing-code status check; it does not deliver the approval challenge or session token.

Token validation is handled by Auth.TokenStore, backed by LemonCore.Store under the :session_tokens namespace. Tokens are validated on each connection attempt and expired tokens are cleaned up lazily. One-time challenges are consumed atomically, so concurrent exchanges cannot mint multiple credentials. Node credentials have a controller-side generation: replacement atomically advances the generation, immediately invalidates the older token and live socket, and rejects results submitted by that stale connection.

The source-checkout execution-node CLI performs the request, approval, and challenge exchange when the connecting operator has pairing scope:

LEMON_NODE_OPERATOR_TOKEN=... ./bin/lemon node join \
  --name worker-1 \
  --controller wss://controller.example/ws \
  --pair \
  --cwd /srv/project

The CLI stores the issued session and recovery credentials on the destination in a private file keyed by durable node ID and bound to the exact controller URL. ID-based lookup does not expose its recovery material unless the caller supplies that exact controller URL. Subsequent starts omit --pair. Re-run with --pair after session expiry: the recovery exchange retains the existing identity and current durable name, then revokes older node sessions as it issues the new one. Controller renames therefore do not strand the destination credential. Compatible legacy records without a recovery credential require the explicit operator-authorized --pair --repair --node-id ID migration path.

Non-loopback plaintext ws:// is rejected by default. Prefer wss://. An explicit --allow-insecure-controller override is acceptable only for development or over a verified encrypted overlay such as Tailscale; for example, --controller ws://controller:4040/ws --allow-insecure-controller after verifying the route stays on that overlay.

Pairing approval is retry-safe for the same pairing ID: an authorized retry reissues a one-time challenge for the existing durable node identity. This lets the joining worker recover if its WebSocket drops after approval or if a challenge response is lost after the controller consumed the challenge.

Method Scopes

Each method declares required scopes. A connection must have at least one matching scope. Methods with an empty scope list are public (no auth required).

ScopePurpose
[] (empty)Public: health, connect, connect.challenge
[:read]Read operations: list, get, status, describe
[:write]Write operations: send, agent, chat, wake, endpoints
[:admin]Admin operations: config, secrets, cron, sessions mutation, install, reload
[:approvals]Approval management: exec.approvals.*, exec.approval.*
[:pairing]Pairing operations: node.pair.*, device.pair.*
[:invoke, :event]Node-only operations: node.invoke.result, node.event, skills.bins
[:control]Device-only operations

API Method Inventory

System and Utility

MethodScopeDescription
healthnonePublic runtime health with BEAM scheduler/memory summary
statusreadSystem status with connections, runs, channels, skills, BEAM VM capacity counters, and cleanup summary
introspection.snapshotreadConsolidated snapshot of agents, sessions, channels, transports plus section summary
logs.tailreadTail recent log lines with filter summary, cleanup flags, and sensitive log-value redaction
models.listreadList available AI models plus capability/provider summaries
providers.statusreadRedacted provider credential readiness, route preview, fallback candidates, config-shape diagnostics, live fallback proof status, and top-level summary
providers.configureadminPreview or apply fallback/pool/reference edits through the shared comment-preserving service; expectedRevision rejects stale applies, destructive changes require exact confirmation, and responses omit credential references
memory.statusreadRedacted memory-provider registry metadata plus provider health and searchable-scope summaries
proofs.statusreadRedacted live-proof diagnostics with top-level counts and launch-gate summaries for Discord DM, Discord slash registration, Discord client-click, provider media, and terminal backends
readiness.statusreadCompact launch-readiness summary for doctor, Telegram/Discord gates, shared proof-gate counts/statuses, provider-media proof, proof totals, unresolved gates with summary reason-kind lists, and cleanup flags
extensions.statusreadRedacted extension/plugin load, conflict, provider, WASM diagnostics, and host/runtime summary
usage.statusreadCurrent usage, provider, quota, and redaction-safe summary backed by LemonCore.UsageDiagnostics
usage.costreadCost breakdown for a date range plus cleanup summary
system-presencereadCurrent presence/resource data plus summary/cleanup flags
system-eventwriteEmit a bounded admin system event with target validation plus summary/cleanup flags
system.reloadadminRuntime reload of module/app/extension/all scopes with lifecycle summary; compile: true recompiles source first on mix-run nodes
update.runadminTrigger a system update with version/check-only/apply summary and cleanup flags (capability-gated)

Agent Management

MethodScopeDescription
agentwriteSubmit an agent run with prompt-cleanup summary
agent.waitreadWait for run completion with bounded result summary and sensitive answer/error redaction
agent.progressreadGet progress for an active session plus bounded progress summary
agent.identity.getreadGet agent capabilities/identity plus capability and cleanup summary
agent.inbox.sendwriteSend message to agent inbox with routing plus prompt-cleanup summary
agent.targets.listreadList agent routing targets plus summary and cleanup flags
agent.directory.listreadList agent directory entries plus session summary and cleanup flags
agent.endpoints.listreadList agent HTTP endpoints plus route summary
agent.endpoints.setwriteConfigure agent endpoint plus route cleanup summary
agent.endpoints.deletewriteRemove agent endpoint plus deletion cleanup summary
agents.listreadList available agents plus directory summary and cleanup flags
agents.files.listreadList agent files plus file-count and cleanup summary
agents.files.getreadGet file content plus bounded content-return summary
agents.files.setadminSet file content plus content-cleanup summary

Session Management

MethodScopeDescription
sessions.listreadList/search sessions with lifecycle metadata and pin/archive filters; raw search text is not echoed
sessions.activereadGet currently active session plus active-run cleanup summary; router unavailability and internal failures use distinct bounded errors without raw reasons
sessions.active.listreadList all active sessions with harness progress plus summary and cleanup flags
sessions.previewreadPreview truncated session messages plus sensitive-preview redaction, truncation summary, and cleanup flags
session.detailreadDeep session/run internals with summary, sensitive preview/run-internal redaction, and explicit opt-ins for full text, raw run events, and run records
sessions.patchadminModify session policy/model/thinking overrides plus patch summary and cleanup flags
sessions.metadata.patchadminSet/clear title and update pin/archive state without echoing title text in the mutation response
sessions.exportreadReturn bounded, always-redacted JSON or Markdown with selected tool fields, digest, and omission summary
sessions.pruneadminPreview or execute stale-session pruning with archived-only/unpinned defaults and an exact-candidate confirmation token
sessions.resetadminClear session history plus cleanup summary
sessions.heartbeatadminInspect or set/pause/resume/clear one live durable session's idle-only recurring prompt; accepts the logical client session key and fails closed on ambiguity
sessions.deleteadminDelete and verify run history, chat state, policy, and lifecycle metadata
sessions.compactadminCompact session storage plus no-text cleanup summary
session.btwwriteAsk a bounded no-tools question against a frozen live session or durable session-key history without mutating the parent conversation

Monitoring and Introspection

MethodScopeDescription
runs.active.listreadActive run list from RunRegistry plus summary and cleanup flags
runs.recent.listreadRecent completed/errored/aborted runs plus status/duration summary and cleanup flags
run.graph.getreadParent/child run graph with optional records/events plus return-state summary and sensitive-internal redaction
run.introspection.listreadIntrospection timeline for one run plus raw-internal return-state summary and sensitive payload redaction
tasks.active.listreadActive task/subagent records plus summary and include/cleanup flags
tasks.recent.listreadRecent terminal task records plus summary and include/cleanup flags

Run and task list methods include compact status, engine, agent/session/run, event, reasoning, and duration summaries for orchestration dashboards without requiring raw graph or record fetches.

Chat

MethodScopeDescription
chat.sendwriteSend message to session; returns runId, sessionKey, and prompt-cleanup summary
chat.abortwriteAbort a running session or run plus target cleanup summary
chat.historyreadGet chat history for a session with summary, beforeId pagination, optional preview truncation, and sensitive-preview redaction when full text is disabled
sendwriteSend a message to a channel (no agent run) plus delivery cleanup summary

Configuration and Secrets

MethodScopeDescription
config.getreadGet config value(s) with sensitive stored values redacted plus cleanup summary
config.setadminSet config value with sensitive response values redacted plus cleanup summary
config.patchadminPartial config update plus value-cleanup summary
config.schemareadGet config schema plus property summary
config.reloadadminReload configuration plus lifecycle and cleanup summary
secrets.listreadList secret metadata plus no-value cleanup summary
secrets.setadminStore secret plus no-value cleanup summary
secrets.deleteadminRemove secret plus no-value cleanup summary
secrets.existsreadCheck if secret exists plus no-value cleanup summary
secrets.statusreadGet redacted secrets store health, fallback, count, and cleanup summary

Cron Jobs

MethodScopeDescription
cron.listreadList cron jobs with redacted prompt/command summaries unless includeTargetText is true
cron.addadminAdd a cron job with target byte counts and cleanup summaries
cron.updateadminUpdate mutable fields of a cron job with changed-field and cleanup summaries
cron.pauseadminPause a cron job by disabling future scheduled launches with cleanup summaries
cron.resumeadminResume a paused cron job with cleanup summaries
cron.abortadminAbort an active cron run by run id with raw-id and cleanup summaries
cron.auditreadList durable cron lifecycle audit events with operator-facing raw-id and cleanup summaries
cron.removeadminRemove a cron job with raw-id and cleanup summaries
cron.runadminManually trigger a cron job with raw-id and cleanup summaries
cron.runsreadList runs for a job with include-option summaries, cleanup flags, and sensitive output redaction
cron.statusreadCron system status with active/recent run, retry, scheduler-lock, suppression, stale-recovery, audit counters, and cleanup summaries

cron.audit supports jobId, runId, cronRunId, action, sinceMs, and limit filters. It is operator-facing and returns raw cron/job/router IDs and lifecycle reason text to authorized clients; the response summary makes that explicit while confirming prompt, command, output, error, credential, and secret text are excluded. Support bundles use a redacted diagnostics shape instead. cron.run and cron.remove return raw ids plus cleanup summaries without prompt, command, output, or error text. cron.runs returns run-history summaries with status counts, output/error byte counts, preview/full-output flags, run-record and introspection include flags, and cleanup metadata that makes operator-requested output previews or internals explicit while redacting sensitive output, error, metadata, run-record, and introspection values. cron.add and cron.update normalize supported schedule shorthands, including every 30m, hourly, every 2h, daily at 9am, weekdays at 09:30, and weekly monday at 8am, into stored 5-field cron expressions. Interval shorthands must divide the enclosing cron field exactly, such as 60 minutes or 24 hours. cron.add accepts either prompt jobs (agentId, sessionKey, prompt) or operator-owned no-agent command jobs (command, optional cwd and env); the two target types are mutually exclusive. cron.update preserves the target type: prompt jobs can update prompt, command jobs can update command, cwd, and env, and agentId / sessionKey remain immutable. cron.list redacts prompt and command text by default, returning byte counts and cleanup summaries; pass includeTargetText: true only for trusted operator views that need the raw target text.

Kanban

MethodScopeDescription
kanban.board.createwriteCreate a board plus board return-state summary
kanban.board.listreadList boards plus filter, status-count, and cleanup summary
kanban.board.getreadGet one board with tasks plus task-count/status and cleanup summary
kanban.board.archivewriteArchive a board plus archive-state summary
kanban.task.createwriteCreate a task plus task/dependency/comment cleanup summary
kanban.task.updatewriteUpdate a task plus task/run/session return-state summary
kanban.task.commentwriteAdd a task comment plus comment-count summary
kanban.dispatcher.startwriteStart a board dispatcher plus worker/concurrency summary
kanban.dispatcher.statusreadRead dispatcher state plus running/worker summary
kanban.dispatcher.stopwriteStop a dispatcher plus stopped-state summary

Kanban board and task methods intentionally return operator-authored names, titles, descriptions, comments, metadata, session keys, and run ids. Their summaries make those returned fields explicit so non-Web clients can choose when to render the full payload.

Exec Approvals

MethodScopeDescription
exec.approvals.getapprovalsGet approval policy plus active pending approvals with summary and redacted structured action metadata for operator surfaces such as MCP OAuth
exec.approvals.setapprovalsSet global approval policy plus mode summary and cleanup flags
exec.approvals.node.getapprovalsGet approval policy for a node plus summary and cleanup flags
exec.approvals.node.setapprovalsSet node approval policy plus mode summary and cleanup flags
exec.approval.requestapprovalsRequest an approval for a tool use plus action cleanup summary
exec.approval.resolveapprovalsResolve a pending approval plus decision cleanup summary

Node Management

MethodScopeDescription
node.listreadList durable paired nodes with live-registry online/offline status plus summary and cleanup flags
node.describereadGet node details with redacted metadata summary and cleanup flags
node.renamewriteRename a node plus summary and cleanup flags
node.invokewriteInvoke a method on one authenticated live node plus arg/result cleanup summary
node.invoke.resultinvokeOwning node reports an invocation result; results from another node are rejected
node.invoke.control.resultinvokeOwning node acknowledges steer/redirect against the exact live invocation and run
node.eventeventNode sends an event (node-only) plus payload summary and cleanup flags
node.pair.requestpairingRequest to pair a node plus pairing-code delivery summary
node.pair.listpairingList pending pairing requests plus summary and cleanup flags
node.pair.approvepairingApprove a pairing request plus token/challenge delivery summary
node.pair.rejectpairingReject a pairing request plus cleanup flags
node.pair.verifypairingVerify a pairing code plus status cleanup summary

Paired names are durably unique per controller, and the live registry enforces the same uniqueness while connections are online. A durable record alone is not executable: node.invoke fails with UNAVAILABLE unless its authenticated WebSocket is registered. Pending invocations are bound to their node ID, fail when that connection disconnects, and support targeted node.invoke.cancel delivery through the internal registry path. Invocation-bound steer and redirect carry only bounded UTF-8 correction text and exact control/invocation/run identity. The controller reports success only after the owning authenticated worker applies the operation to its live native executor context.

For coding delegation the supported worker method is versioned coding_agent.run. The WebSocket payload contains JSON-safe execution request data, not resolved model credentials, callbacks, source process state, or executor options. The destination selects its own credentials and validates its own cwd. This is native Lemon execution, not a vendor CLI runner.

Request and result payloads are enforced against the hello policy's maxPayload byte limit (1 MiB by default) and shared depth/item-count bounds. Raw request arguments exist only in the targeted live dispatch; durable invocation records retain a content-free argument summary. Raw results still travel privately to the invocation's source recipient, and awaited browser.request calls consume that private delivery directly. Durable invocation status and node.invoke.completed events retain only content-free byte/type/depth/item summaries. They never persist or broadcast the raw request, remote result, or error.

Channels and Transports

MethodScopeDescription
commands.catalogreadReturn the portable, JSON-safe command catalog with aliases, descriptions, argument and busy-state metadata, semantic capability ids, category counts, and a versioned summary; this method never executes a command
background.startwriteStart an isolated full-tool native agent session, validating and normalizing any thinkingLevel, and return its durable lifecycle id immediately
background.listreadList sanitized durable background-run lifecycle summaries, optionally filtered by status
background.statusreadRead one sanitized background-run lifecycle summary
background.resultreadRead the visible answer after completion, or report that the run is not ready
background.cancelwriteCancel a queued or running background session by durable id
channels.statusreadStatus of configured channel adapters plus Telegram/Discord diagnostics, proof, shared launch-gate readiness, compact gate status/reason maps, and cleanup summaries
transports.statusreadStatus of configured legacy gateway transports plus registry/module health summary
channels.logoutadminLogout from a channel plus credential/state cleanup summary

Background and side-query RPCs treat the registered agent runtime as an untrusted public-boundary provider. Lifecycle maps are projected through an allowlist, persisted error values are replaced with stable errorCode values, and RPC failures return fixed operation-specific messages plus a bounded details.code. Provider terms, paths, credential text, and arbitrary fields are never copied into the JSON-RPC response.

Skills

MethodScopeDescription
skills.statusreadList skills with readiness details plus activation/source/missing-requirement summaries
skills.hermes.catalogreadBrowse the live official Hermes catalog by category, collection, or query
skills.binsreadGet skill bin paths plus bin/requirement counts and cleanup summary
skills.installadminInstall a skill plus install-source return-state and approval-context cleanup summary; audit blocks require a distinct exact-bundle security-override approval and denial returns a safe permission error
skills.updateadminUpdate/configure a skill plus env-key/update-mode summary with sensitive env response redaction

Portable Blueprints

MethodScopeDescription
blueprints.listreadList valid versioned bundles in the canonical local catalog without returning paths or content
blueprints.inspectreadInspect one bundleId with normalized manifest, provenance, and cleanup summaries
blueprints.validatereadRe-run manifest, lint, policy, and deterministic skill audit for one bundleId
blueprints.previewreadReturn the exact profile skill + cron plan and fresh confirmationDigest
blueprints.activateadminRe-plan and activate only when the exact confirmation digest still matches

Blueprint RPC never accepts root or path. It resolves a safe bundleId directly below ~/.lemon/bundles, rejects traversal, symlinked catalog entries, and manifest/directory ID mismatches, and returns no absolute paths, skill bodies, prompt text, commands, or secret values. Activation targets a derived profile workspace and creates the disabled or enabled agent cron definition only through the create-once CronManager API. See the skills user guide. Source and packaged lemon blueprints commands are thin authenticated clients of these methods; a bundle ID previews by default, and activation requires the fresh plan's exact confirmation digest. The Bun TUI exposes the same boundary through /blueprints and /blueprint. It renders only bounded IDs, counts, actions, booleans, and digests; re-previews immediately before activation; never queues the admin mutation offline; and preserves the profile draft when a plan is refused or stale.

Events and Subscriptions

MethodScopeDescription
events.subscribereadSubscribe to event topics with per-connection state, delivery filtering, and summary/cleanup flags
events.unsubscribereadUnsubscribe from event topics or clear all per-connection subscriptions
events.subscriptions.listreadList current subscriptions plus run/session subscription summary and cleanup flags
events.ingestwriteIngest bounded external events with target validation plus summary/cleanup flags

Voice / TTS (capability-gated)

MethodScopeDescription
voicewake.getreadGet voicewake settings plus config summary and redaction flags
voicewake.setwriteSet voicewake enabled/keyword plus audio/transcript cleanup summary
tts.statusreadTTS status plus active-provider readiness, provider counts, and redaction flags
tts.providersreadList TTS providers plus provider/voice summary
tts.enablewriteEnable TTS plus config-write cleanup summary
tts.disablewriteDisable TTS plus config-write cleanup summary
tts.convertwriteConvert text to speech plus provider/format/audio-byte cleanup summary
tts.set-providerwriteSet active TTS provider plus config-write cleanup summary

Device Pairing (capability-gated)

MethodScopeDescription
device.pair.requestpairingRequest to pair a device plus pairing-code delivery summary
device.pair.approvepairingApprove a device pairing plus token/challenge delivery summary
device.pair.rejectpairingReject a device pairing plus cleanup flags
connect.challengenoneExchange pairing challenge for a session token plus token-delivery summary

Wizard (capability-gated)

MethodScopeDescription
wizard.startadminStart a wizard flow plus step-count and cleanup summary
wizard.stepadminAdvance wizard step plus current-step/data-key summary with sensitive response data redacted
wizard.canceladminCancel a wizard plus cancellation cleanup summary

Automation

MethodScopeDescription
wakewriteWake an agent plus returned-id, prompt-byte, and cleanup summary
set-heartbeatswriteEnable/configure heartbeat monitoring plus summary and prompt cleanup flags
last-heartbeatreadGet last heartbeat for an agent plus response summary and redaction flags
talk.modewriteGet or set talk mode for a session plus audio/transcript cleanup summary
browser.statusreadInspect local browser driver status, artifacts, browser nodes, and live browser proof state
browser.requestwriteSend a browser request with route-policy summaries and optional private awaited result delivery
media.statusreadInspect redacted generated-media job/artifact metadata plus provider-backed media proof lane state
checkpoint.statusreadInspect redacted checkpoint-store metadata plus filtered lifecycle event counts/history
checkpoint.diffreadPreview filesystem changes for a checkpoint with path/diff cleanup summary
checkpoint.restorewriteRestore all or selected checkpoint paths with restore cleanup summary
lsp.diagnostics.statusreadInspect redacted diagnostics checker capability metadata plus recent LSP proof artifacts/checks and summary
lsp.server.startwriteStart a supervised LSP stdio session with session cleanup summary
lsp.server.initializewriteRun initialize and send the LSP initialized notification with protocol cleanup summary
lsp.document.openwriteSend a textDocument/didOpen notification with document cleanup summary
lsp.document.changewriteSend a textDocument/didChange notification with document cleanup summary
lsp.document.closewriteSend a textDocument/didClose notification with document cleanup summary
lsp.server.requestwriteSend a JSON-RPC request over a supervised LSP stdio session with protocol cleanup summary
lsp.server.stopwriteStop a supervised LSP stdio session with session cleanup summary
terminal.backends.statusreadInspect registered terminal backend metadata, capabilities, policy, live proof state, Docker hardening, and top-level summary
goal.setwriteSet the durable goal for a session with redacted objective summaries
goal.statusreadInspect one goal or list durable goals with redacted objective summaries
goal.pausewritePause the durable goal for a session with redacted objective summaries
goal.resumewriteResume the durable goal for a session with redacted objective summaries
goal.continuewriteSubmit one supervised continuation run for an active goal with cleanup summaries
goal.loop.oncewriteRun one preview judge tick for an active goal with cleanup summaries
goal.loop.startwriteStart a bounded supervised autonomous goal loop with cleanup summaries; pass auto: true to persist opt-in scheduling
goal.loop.statusreadInspect the bounded goal loop and persisted auto state for a session with cleanup summaries
goal.loop.stopwriteStop a bounded supervised autonomous goal loop, disable persisted auto scheduling, and return only a bounded router-abort status (accepted, outcome_unknown, unavailable, rejected, or not_needed)
goal.clearwriteClear the durable goal for a session with cleanup summaries

Event System

The EventBridge subscribes to LemonCore.Bus topics and maps internal bus events to WebSocket event frames. Events fan out through a supervised task, then each WebSocket connection applies its current events.subscribe / events.unsubscribe topic state before pushing a frame. New connections keep legacy all-event delivery until they set explicit subscriptions; events.unsubscribe with no topics clears the connection to no event delivery.

Subscribed Bus Topics

  • run:* -- Run lifecycle events (dynamic subscription per run)
  • session:* -- Session lifecycle and task events (dynamic subscription per session)
  • channels -- Channel-related events
  • exec_approvals -- Approval request/resolution events
  • cron -- Cron job lifecycle events
  • goals -- Durable goal lifecycle events
  • goals -- Durable goal lifecycle, continuation, and loop verdict events
  • system -- System events (shutdown, health, tick, talk mode)
  • nodes -- Node pairing events
  • presence -- Connection presence events

WebSocket Events

EventTrigger
agentRun started/completed, tool use; tool-use events preserve nested action.detail metadata, including result_meta failure fields such as error_type and exit_code
chatChat delta/streaming content
goalDurable goal set/pause/resume/complete/clear, supervised continuation, or loop verdict
presenceConnection count changed
tickHeartbeat tick
heartbeatAgent heartbeat or alert
exec.approval.requestedApproval needed, including structured action metadata for operator UI controls such as MCP OAuth links
exec.approval.resolvedApproval decided or timed out, including approval id, decision, and pending approval run/session/agent/tool metadata when available
cronCron job started/completed
cron.jobCron job created/updated/deleted
cron.auditCron lifecycle audit event recorded
task.startedSubtask/subagent started
task.completedSubtask/subagent completed
task.errorSubtask/subagent errored
task.timeoutSubtask/subagent timed out
task.abortedSubtask/subagent aborted
run.graph.changedRun graph/status changed
shutdownSystem shutting down
healthHealth status changed
talk.modeTalk mode changed
node.pair.requestedNode wants to pair
node.pair.resolvedNode pairing approved/rejected
node.invoke.requestOperator invoked a node method
node.invoke.completedNode invoke completed
device.pair.requestedDevice wants to pair
device.pair.resolvedDevice pairing approved/rejected
voicewake.changedVoicewake config changed
customCustom event via system-event

State Versioning

State-versioned events include a stateVersion map for client reconciliation. Version counters are bumped for presence, health, and cron keys on relevant events. Clients can use these versions to detect stale state and reconcile without re-fetching.

Presence System

LemonControlPlane.Presence is an ETS-backed GenServer that tracks all connected WebSocket clients. It provides:

  • Registration/unregistration on connect/disconnect
  • Role-based counting (operators, nodes, devices)
  • Client lookup by connection ID
  • Broadcast to all or filtered connected clients
  • Automatic presence_changed bus event emission on changes

Schema Validation

Method parameters are validated against schemas defined in Protocol.Schemas before dispatch. Server-to-client event payloads that need stable client contracts also have event schemas in the same module and can be checked with validate_event/2.

  • Required fields with types -- requests missing these fields are rejected.
  • Optional fields with types -- provided values are type-checked.
  • Supported types: :string, :integer, :boolean, :map, :list, :any.

Methods or events without a schema entry accept any parameters. Approval events currently have schema-backed payload contracts so operator clients can rely on exec.approval.requested and exec.approval.resolved metadata, including decision: "timeout" when a pending approval expires.

Capability-Gated Methods

Some method groups can be enabled/disabled via application configuration. Disabled capability methods are not registered in the ETS table at startup and return METHOD_NOT_FOUND.

CapabilityMethods
voicewakevoicewake.get, voicewake.set
ttstts.status, tts.providers, tts.enable, tts.disable, tts.convert, tts.set-provider
updatesupdate.run
device_pairingdevice.pair.*, connect.challenge
wizardwizard.start, wizard.step, wizard.cancel

Configuration

Application Environment

KeyTypeDefaultDescription
:portinteger4040 (prod), 0 (test)HTTP server port
:capabilities:default / list / map:defaultEnable/disable capability groups
:git_commitstring / nilnilGit commit hash for server info

Capabilities Configuration

# Enable all capabilities (default)
config :lemon_control_plane, capabilities: :default

# Enable only specific capabilities
config :lemon_control_plane, capabilities: [:tts, :voicewake]

# Fine-grained control with a map
config :lemon_control_plane, capabilities: %{tts: true, wizard: false}

Port Configuration

config :lemon_control_plane, port: 4040

In test mode, the port defaults to 0 (OS-assigned) to avoid conflicts.

Dependencies

Umbrella Dependencies

AppRelationship
lemon_coreStore, secrets, event bus, idempotency, telemetry
lemon_routerRun submission (LemonRouter.submit/1, LemonRouter.RunOrchestrator)
lemon_channelsChannel backends, Outbox for send method
lemon_skillsSkill management
lemon_automationCron manager, heartbeat features
coding_agentCompile-time only (not started at runtime)
agent_coreProvider credential readiness checks through the model runtime
aiAI/model integration

External Dependencies

PackageVersionPurpose
bandit~> 1.5HTTP/WebSocket server
plug~> 1.16HTTP routing and middleware
websock_adapter~> 0.5WebSocket adapter for Plug
jason~> 1.4JSON encoding/decoding

Running

# Start the umbrella (control plane starts automatically)
iex -S mix

# From a destination source checkout, pair a named native execution node
LEMON_NODE_OPERATOR_TOKEN=... ./bin/lemon node join --name worker-1 \
  --controller ws://localhost:4040/ws --pair --cwd /path/to/project

# Run tests
mix test apps/lemon_control_plane

# Run a specific test file
mix test apps/lemon_control_plane/test/lemon_control_plane/methods/control_plane_methods_test.exs

# Safe catalog resolution, exact confirmation, and duplicate-safe activation
mix test apps/lemon_control_plane/test/lemon_control_plane/methods/blueprints_test.exs --seed 1

The server starts automatically via the OTP application supervision tree. Connect with any WebSocket client to ws://localhost:4040/ws and send a connect request to begin.