WebSocket Transport

Transport

Biomata Engine ships with a JSON-over-WebSocket transport that exposes a running simulation to any client (Unity, Unreal, browser, test harness). Source: src/transport/websocket/.

WebSocket server

Starting from CLI

bash
# Host-driven mode (default): client sends tick requests
biomata-ws --config examples/engine_owned/sim.yaml --port 8765

# Autonomous mode: backend self-ticks, client subscribes to events
biomata-ws --config examples/engine_owned/sim.yaml --port 8765 --mode autonomous

Programmatic server

python
import asyncio
from src.engine.simulation import Simulation
from src.service import create_session, TickMode
from src.transport.websocket.server import WebSocketServer

async def main():
    # From YAML config
    server = await WebSocketServer.from_config(
        "examples/engine_owned/sim.yaml",
        port      = 8765,
        tick_mode = TickMode.HOST_DRIVEN,
    )
    await server.start()
    print(f"Listening on ws://0.0.0.0:{server.port}")
    await server.wait_for_termination()

asyncio.run(main())
python
from src.plugins.external.world import HostedWorld
from src.engine.simulation import Simulation
from src.service import create_session
from src.transport.websocket.server import WebSocketServer

# Fully programmatic — useful when you need to configure agents/world in code
world   = HostedWorld()
sim     = Simulation(agents=[...], world=world, registry=registry)
session = create_session(sim)
server  = WebSocketServer(session, host="127.0.0.1", port=8765)
await server.start()
await server.serve()
Default host

The server defaults to host="127.0.0.1" (loopback only). To expose on a LAN interface pass host="0.0.0.0" explicitly — only do this inside a trusted network, as the snapshot restore endpoint deserialises binary data and performs HMAC verification to reject tampered payloads.

WebSocketServer API

MethodDescription
await start()Bind and start accepting. Returns bound port.
await stop()Close and drain connections.
await serve()Start + block on SIGINT/SIGTERM + stop.
await wait_for_termination()Block until server closed (use with start()).
portBound port (useful when started with port=0).

WebSocket Protocol v1

The protocol uses JSON text frames over WebSocket (RFC 6455). All frames are UTF-8 encoded. Binary frames are silently ignored. The authoritative Python-side definitions are in src/transport/websocket/protocol.py.

Design rationale

JSON was chosen over protobuf because:

  • No codegen — any language with JSON and WebSocket can implement a client from this document
  • Debuggable — wscat, websocat, and browser DevTools work out of the box
  • Latency is not the bottleneck — LLM brain calls dominate at 100–5000 ms per tick; JSON parse overhead is under 1 ms

Frame envelope

Every frame is a JSON object. All frames carry "type" and "v". Unknown top-level fields MUST be ignored by both sides for forward compatibility.

hlo — Server Hello

Sent by the server immediately on connection, before any client request. Clients MUST NOT send requests until the hello frame is received.

json
{
  "type":           "hlo",
  "v":              1,
  "server":         "biomata-engine",
  "server_version": "0.5.0",
  "session_id":     "a3f2c1d0-e29b-41d4-a716-446655440000",
  "tick_mode":      "host_driven",
  "capabilities":   ["tick", "register_agent", "remove_agent",
                     "send_observation", "snapshot", "restore", "events",
                     "agent.register", "agent.unregister", "agent.list",
                     "roles.list"]
}

Read tick_mode first and branch client behavior accordingly:

  • "host_driven" — client drives timing with tick requests; do NOT call pause/resume
  • "autonomous" — subscribe to events; use pause/resume to control the loop; do NOT call tick

req — Client Request

json
{
  "type":   "req",
  "v":      1,
  "id":     "550e8400-e29b-41d4-a716-446655440000",
  "method": "tick",
  "params": { }
}

res — Server Response

json
// Success
{
  "type":   "res",
  "v":      1,
  "id":     "550e8400-...",
  "ok":     true,
  "result": { }
}

// Error
{
  "type":  "res",
  "v":     1,
  "id":    "550e8400-...",
  "ok":    false,
  "error": {
    "code":    -32601,
    "name":    "METHOD_NOT_FOUND",
    "message": "unknown method 'foo'"
  }
}

evt — Server Event

json
{
  "type":       "evt",
  "v":          1,
  "session_id": "a3f2c1d0-...",
  "seq":        42,
  "event_type": "tick_end",
  "tick":       5,
  "agent_id":   "engine",
  "ts":         "2026-05-23T14:32:01.123Z",
  "data":       { }
}

seq is a per-connection monotonically increasing counter. A gap in seq indicates dropped events (backpressure — default queue: 2048 events).

Methods

health_check

Liveness probe. Returns current session state.

json
// Request
{"type":"req","v":1,"id":"...","method":"health_check","params":{}}

// Result
{
  "status":        "ok",
  "session_state": "running",
  "tick":          42,
  "agent_count":   5,
  "tick_mode":     "host_driven"
}

agent.register

Dynamically add an agent to the running simulation. The brain class is imported by dotted path. Prefer this dot-notation form for new integrations — it groups brain and memory config together and aligns with sim.yaml syntax.

json
{
  "method": "agent.register",
  "params": {
    "id":   "npc_guard_01",
    "name": "Guard",
    "brain": {
      "class":  "src.plugins.builtin.idle_brain.brain.IdleBrain",
      "config": {}
    },
    "capabilities": ["patrol"],
    "metadata": { "scene": "level_01" },
    "reconnect": false
  }
}
// Result: {"id": "npc_guard_01", "reconnected": false, "capabilities": ["patrol"]}

Pass "reconnect": true on WebSocket reconnect to avoid AGENT_EXISTS errors when the agent is already registered. The server returns the existing agent state unchanged.

agent.unregister

Remove a registered agent and release its resources. The backend closes the brain, removes the agent from the scheduler, and emits an agent_unregistered event.

json
{"method":"agent.unregister","params":{"id":"npc_guard_01"}}
// Result: {"id": "npc_guard_01"}

agent.list

Return all currently registered agents — both YAML-static agents loaded at startup and runtime-dynamic agents registered via WebSocket. Order matches the scheduler's agent list.

json
{"method":"agent.list","params":{}}
// Result:
{
  "agents": [
    { "id": "guard_001", "name": "Aldric", "capabilities": ["patrol", "authority"], "metadata": {} },
    { "id": "scout_001", "name": "Scout",  "capabilities": ["patrol"], "metadata": { "scene": "level_01" } }
  ],
  "count": 2
}

register_agent / remove_agent (legacy)

The original flat-param methods remain fully functional. Existing clients need no changes.

json
{
  "method": "register_agent",
  "params": {
    "agent_id":    "npc_guard_01",
    "agent_name":  "Guard",
    "brain_class": "src.plugins.builtin.idle_brain.brain.IdleBrain",
    "brain_config": {},
    "memory_class": "",
    "memory_config": {}
  }
}
// Result: {"agent_id": "npc_guard_01"}

{"method":"remove_agent","params":{"agent_id":"npc_guard_01"}}
// Result: {"agent_id": "npc_guard_01"}
Legacy methodDot-notation equivalentKey difference
register_agentagent.registerUses agent_id / brain_class flat keys; result uses agent_id
remove_agentagent.unregisterUses agent_id instead of id
(none)agent.listNew in Phase 2 — no legacy equivalent

send_observation

Pre-load one agent's observation for the next tick without triggering cognition. Requires a HostedWorld.

json
{
  "method": "send_observation",
  "params": {
    "agent_id":    "npc_guard_01",
    "observation": {"location": "gatehouse", "health": 100}
  }
}

tick

Execute one cognition cycle. HOST_DRIVEN mode only.

json
{
  "method": "tick",
  "params": {
    "agent_observations": [
      {"agent_id": "npc_001", "observation": {"position_x": 3.5, "position_z": -2.0}}
    ],
    "world_metadata": {"weather": "clear", "time_of_day": "noon"}
  }
}
// Result:
{
  "tick": 7,
  "decisions": [
    {
      "agent_id":        "npc_001",
      "agent_name":      "Guard",
      "action":          "navigate",
      "parameters":      {"target_x": 5.0, "target_z": 0.0},
      "outcome_text":    "moving to patrol point",
      "engine_commands": [{"type": "navigate", "x": 5.0, "y": 0.0, "z": 0.0}],
      "error":           null
    }
  ],
  "errors": []
}

pause / resume

Control the autonomous tick loop. AUTONOMOUS mode only.

json
{"method":"pause","params":{}}   // Result: {"state": "paused"}
{"method":"resume","params":{}} // Result: {"state": "running"}

snapshot / restore

json
{"method":"snapshot","params":{}}
// Result:
{
  "data_b64":    "<base64 blob>",
  "hmac_sha256": "<64-hex-char HMAC-SHA256 tag>",
  "tick":        42,
  "created_at":  "2026-05-23T..."
}

{"method":"restore","params":{"data_b64": "<same base64 blob>", "hmac_sha256": "<same tag>"}}
// Result: {"tick": 42}
HMAC required

The hmac_sha256 field is required for restore. The server verifies it with an ephemeral per-process signing key before deserialising. A missing, malformed, or mismatched tag returns error code -5 (SNAPSHOT_INVALID). Pass the data_b64 and hmac_sha256 values verbatim from the snapshot response — do not modify them.

subscribe_events / unsubscribe_events

json
// Subscribe to specific event types (omit event_types for all events)
{"method":"subscribe_events","params":{"event_types":["tick_end","action_completed"]}}
// Result: {"subscribed": true, "event_types": ["tick_end", "action_completed"]}

{"method":"unsubscribe_events","params":{}}
// Result: {"unsubscribed": true}

Error codes

CodeNameMeaning
-32700PARSE_ERRORFrame was not valid JSON. id is null.
-32600INVALID_REQUESTRequired envelope field missing or wrong type.
-32601METHOD_NOT_FOUNDThe method string is not recognized.
-32602INVALID_PARAMSMethod params missing or wrong type.
-32603INTERNAL_ERRORUnhandled server exception. Report as a bug.
-32000VERSION_MISMATCHClient v not supported by this server.
-1SESSION_ERROROperation invalid in current session state.
-2AGENT_EXISTSregister_agent with an already-registered ID.
-3AGENT_NOT_FOUNDremove_agent with an unregistered ID.
-4IMPORT_ERRORbrain_class dotted path could not be imported.
-5SNAPSHOT_INVALIDhmac_sha256 verification failed or snapshot data is malformed.
-6VALIDATION_ERRORAgent definition failed structural validation. error.message names all failing fields.

Event types

event_typeagent_idNotable data fields
tick_start"engine"World metadata dict
tick_end"engine"{"tick": N}
brain_decidedagent id{"action", "parameters"}
action_completedagent id{"action", "outcome", "tick"}
action_failedagent id{"action", "error", "tick"}
agent_step_erroragent id{"error", "tick"}
agent_registeredagent id{"agent_name", "capabilities", "metadata"}
agent_unregisteredagent id{"agent_name"}

SDK implementation checklist

When implementing a new SDK client (Unreal, Godot, browser, etc.) targeting Protocol v1:

  • Connect via WebSocket (RFC 6455), UTF-8 JSON text frames only
  • Wait for "type":"hlo" frame before sending requests; record server_version, tick_mode, and capabilities. Check for "agent.register" in capabilities before using the dot-notation agent methods.
  • Log a warning (do not disconnect) if hello "v" differs from PROTOCOL_VERSION
  • Branch on tick_mode: "host_driven" → use tick; "autonomous" → subscribe to events, use pause/resume
  • Send "v": 1 in every req frame
  • Generate a UUID v4 id per request; correlate responses by id
  • Handle "ok": false with structured error object (code + name + message); switch on error.code
  • Handle "type":"evt" frames independently of pending requests
  • Track evt.seq to detect dropped events (backpressure)
  • Implement a per-call timeout; fail pending requests on disconnect
  • Serialize sends — WebSocket sends are not concurrent-safe on most platforms