Skip to content

Workers and Agents

Sol orchestrates swarms of Luna worker agents over ZMQ and JSONL transports. This guide covers connecting workers, the wire protocol, agent modes, and headless operation.

Transport Comparison

FeatureZMQ DEALERJSONL Stdio
Protocolmsgpack over ZMQJSON lines over stdin/stdout
LanguageAny (ZMQ + msgpack bindings)Any (stdin/stdout)
Process managementExternal (worker manages itself)Sol spawns via erlexec
Streaming tokensYesYes
Task cancellationYesYes
Child task spawningYesNo
ReconnectionAutomatic (ZMQ)Process restart
Dependencieslibzmq, msgpackNone
Best forLong-running workers, polyglotShort-lived workers, simple agents

Connection Model

Workers connect to Sol's ZMQ ROUTER gateway as DEALER sockets. Communication is asynchronous: workers announce readiness, Sol dispatches tasks, workers stream results back.

Worker (DEALER) <--> Sol ROUTER (port 5555)

Multiple workers can connect simultaneously. Sol routes tasks to the first available (ready) worker. If no workers are ready, tasks queue until one becomes available.

Connect and Register

The ZMQ gateway uses a dedicated sharded architecture for high throughput:

  • sol_zmq_sender — owns the ROUTER socket, serializes all outbound sends through a dedicated gen_server
  • sol_zmq_recv_dispatcher — dedicated gen_server for inbound message routing
  • sol_zmq_result_router — routes child task results and manages parent/child cleanup
  • sol_zmq_recv_bridge — recv loop with error backoff, extracted from gateway
  • ETS indexessol_zmq_tasks and sol_zmq_workers tables for O(1) lookups
  • Plugin tool executor poolsimple_one_for_one supervisor isolates plugin tool execution from the gateway
python
import zmq
import msgpack

ctx = zmq.Context()
sock = ctx.socket(zmq.DEALER)
sock.setsockopt_string(zmq.IDENTITY, "my-worker-1")
sock.connect("tcp://127.0.0.1:5555")

sock.send_multipart([b"", msgpack.packb({
    "type": "ready",
    "worker_id": "my-worker-1",
    "capabilities": ["python", "streaming"]
}, use_bin_type=True)])

Receive and Execute Tasks

python
poller = zmq.Poller()
poller.register(sock, zmq.POLLIN)

while True:
    events = dict(poller.poll(timeout=1000))
    if sock not in events:
        continue

    frames = sock.recv_multipart()
    msg = msgpack.unpackb(frames[-1], raw=False)
    msg_type = msg.get("type")

    if msg_type == "task":
        task_id = msg["task_id"]
        prompt = msg["prompt"]

Stream Results

python
sock.send_multipart([b"", msgpack.packb({
    "type": "token",
    "task_id": task_id,
    "content": "partial output..."
}, use_bin_type=True)])

sock.send_multipart([b"", msgpack.packb({
    "type": "result",
    "task_id": task_id,
    "status": "ok",
    "content": "final output"
}, use_bin_type=True)])

sock.send_multipart([b"", msgpack.packb({
    "type": "ready",
    "worker_id": "my-worker-1",
    "capabilities": ["python", "streaming"]
}, use_bin_type=True)])

Handle Cancellation

python
if msg_type == "cancel":
    task_id = msg["task_id"]
    sock.send_multipart([b"", msgpack.packb({
        "type": "result",
        "task_id": task_id,
        "status": "cancelled",
        "content": ""
    }, use_bin_type=True)])

Send ready messages every 5 seconds when idle to maintain registration. A full Python reference implementation is at example/python/zmq_worker.py.

See ZMQ Protocol for the complete specification.

JSONL Stdio Workers

Sol spawns an OS process via erlexec and communicates via JSON lines over stdin/stdout. The worker reads JSON objects from stdin (one per line) and writes JSON objects to stdout (one per line, flushed after each).

Message Types

Worker to Sol (stdout)

TypeFieldsDescription
ready--Announce worker is ready
tokenid, contentStreaming partial output
resultid, status, contentTask completion
loglevel, messageDiagnostic log

Sol to Worker (stdin)

TypeFieldsDescription
taskid, prompt, optsTask assignment
cancelidCancel running task

Minimal Worker Example

python
#!/usr/bin/env python3
import sys
import json

def send(msg):
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()

send({"type": "ready"})

for line in sys.stdin:
    msg = json.loads(line.strip())
    if msg.get("type") == "task":
        task_id = msg["id"]
        prompt = msg["prompt"]
        send({"type": "token", "id": task_id, "content": "Working...\n"})
        send({"type": "result", "id": task_id, "status": "ok", "content": f"Done: {prompt}"})
    elif msg.get("type") == "cancel":
        send({"type": "result", "id": msg["id"], "status": "cancelled", "content": ""})

Spawn Configuration

Configure in sys.config:

erlang
{worker_path, "/path/to/my-worker"},
{max_workers, 16}

Or override per-spawn via the HTTP API:

bash
zerg spawn --worker-path /path/to/my-worker

The worker_path is validated against a configurable prefix to prevent arbitrary binary execution. Tasks have a 300-second timeout. Worker stdout is buffered up to 256KB.

Worker Configuration

Config KeyDefaultDescription
worker_path"client/build/luna"Path to worker binary
max_workers16Maximum concurrent workers
zmq_router_port5555ZMQ ROUTER socket port
zmq_pub_port5556ZMQ PUB event socket port
zmq_bind_addr"127.0.0.1"ZMQ bind address
zmq_enabledtrueEnable ZMQ gateway

Agent Modes

Luna workers operate in 6 built-in agent modes, each tuned for a specific task category. Mode selection changes the system prompt, tool permissions, and agent behavior.

ModePurposeTool Policy
BuildCode creation and modificationRead/search allowed, writes require approval
PlanAnalysis and planningRead/search allowed, all writes denied
ExploreCodebase explorationRead/search/lsp only, all writes denied
ReviewCode review and auditRead/search/web allowed, writes denied
GeneralGeneral-purpose tasksAll tools allowed, bash/delete require approval
CoordinateMulti-agent orchestrationAll tools allowed (orchestrator needs full access)

Switch modes with Tab in the TUI or the switch_mode tool:

> /mode build

User-defined modes can be registered via the agent configuration.

Headless Mode

Run Luna without the TUI for scripted or CI/CD workflows:

bash
luajit client/main.lua --print "Analyze this codebase and report issues"
luajit client/main.lua --print "Fix the bug in main.rs" --permission-mode allow-all

Use --demo for a self-contained demonstration run. Output is written to stdout in plain text.

Monitoring Workers

bash
zerg zmq-workers
zerg zmq-status
zerg agents
zerg task-status <id>

Released under the MIT License.