Engine

Covers Engine 2.0.0

Using TEMPEST

Connect clients, call operations, receive telemetry, and follow background jobs.

TEMPEST carries engine operations and telemetry. You can use a client library, or implement the connection yourself with ZeroMQ sockets or HTTP and WebSocket. This guide covers the wire connection; the operation reference describes each operation’s application payload.

Connect to the right endpoint

For an engine with base port 7000:

EndpointPurpose
tcp://host:7000ZeroMQ request/response operations.
tcp://host:7001Outbound telemetry for ZeroMQ subscribers.
http://host:7002/tempest/operationWeb operations through HTTP POST.
ws://host:7002/tempest/telemetryWeb telemetry connection.

Pass the base port to the DARTWIC Python and React wrappers; they derive the appropriate transport endpoints. Host, base port, and password come from the engine’s configuration. Configure TLS through your deployment’s web infrastructure when using a secure browser connection.

DARTWIC Share connects to the peer’s existing base and telemetry ports. It does not bind a fresh pair of local server ports for each peer.

Register a raw client

Connecting a socket does not register a TEMPEST client. The connection sequence is:

  1. Send register_client with an empty clientId and the engine password.
  2. Check error, then save the server-assigned payload.clientId.
  3. Include that ID in every operation, and send periodic heartbeat operations while connected.
  4. For telemetry, connect the separate receiving socket and request any engine-specific subscriptions.
  5. Send unregister_client before closing a session normally.

Registration is a normal operation envelope. Send this JSON as one ZeroMQ message or as the body of an HTTP POST to /tempest/operation:

{
  "clientId": "",
  "name": "register_client",
  "payload": {
    "serverPassword": "YOUR_ENGINE_PASSWORD",
    "username": "raw-socket-example"
  },
  "timestamp": 0
}

Use the exact key serverPassword inside payload. username is optional display metadata, not a separate account login. Other registration fields are retained as client metadata; the password is excluded. Supply only metadata intended to describe your client.

In these wire examples, timestamp: 0 is a placeholder; send the current Unix-epoch time in milliseconds. A successful registration looks like:

{
  "clientId": "SERVER_ASSIGNED_ID",
  "name": "register_client",
  "error": false,
  "payload": {
    "clientId": "SERVER_ASSIGNED_ID",
    "node_name": "PRIMARY_NODE",
    "instance_number": 1
  },
  "timestamp": 0
}

Both clientId fields contain the assigned ID. node_name identifies the server’s engine; instance_number distinguishes clients with the same username. An incorrect password returns error: true and an error message in payload.error. Do not continue with an empty ID.

For subsequent calls, put the assigned ID in the outer envelope:

{
  "clientId": "SERVER_ASSIGNED_ID",
  "name": "argus/get-active-events",
  "payload": { "limit": 100, "offset": 0 },
  "timestamp": 0
}

The password is needed at registration, not on every operation. A ZeroMQ routing identity and the JSON clientId serve different purposes: ZeroMQ routes a reply to a socket, while TEMPEST uses clientId to find the registered session. Do not invent the TEMPEST ID or substitute the socket identity.

Keep the session alive and reconnect

Operation namePayloadResult
register_client{ "serverPassword": "...", "username": "..." }Allocates a new client ID.
heartbeat{}Returns { "status": "ok" }.
unregister_client{}Removes the session and cancels its detached jobs; success has an empty payload.

Use the saved ID for heartbeat and unregister operations. The current server expires clients after 15 seconds without an operation, checked by maintenance roughly once per second. A heartbeat every 5 seconds matches the web and Python clients; use enough margin for your connection and operation latency. Receiving telemetry does not refresh this timer, and sending WebSocket telemetry does not replace the operation heartbeat.

The current server handles heartbeat before its registration check, so a successful heartbeat alone does not prove that an old ID remains registered. If an ordinary operation returns Client not registered, register again and restore subscriptions using the new ID. Re-register after a server restart as well; client IDs and subscriptions are not durable across sessions. Avoid registering repeatedly while a working session is already active.

Use your own ZeroMQ sockets

The base port speaks ZeroMQ framing, not newline-delimited JSON over plain TCP. Use a ZeroMQ implementation such as pyzmq; if you want standard web transports, use the HTTP/WebSocket path below.

ConnectionClient socketFrames visible to application code
Base port, e.g. 7000DEALER connecting to the server’s ROUTERSend one UTF-8 JSON request frame; receive one JSON response frame.
Base port + 1, e.g. 7001SUB connecting to the server’s PUBReceive two frames: topic string, then JSON telemetry envelope.

Use DEALER, not REQ: the server’s reply does not include the empty delimiter required by REQ/REP framing. ZeroMQ adds its routing identity internally; do not add an identity or empty delimiter to the application message.

Keep at most one outstanding operation per DEALER socket. The server can execute requests concurrently and return replies out of order, and the operation envelope has no unique per-request correlation ID. If a request times out, close that socket before issuing another call so a late reply cannot be mistaken for the next result. Multiple independently owned sockets may use the same registered clientId; keep heartbeat traffic separate when a long-running operation could otherwise delay it.

Working Python example without a DARTWIC or TEMPEST library

Install pyzmq with python -m pip install pyzmq. Set DARTWIC_PASSWORD, then run this against an engine on base port 7000. It registers, requests a live diagnostic channel, receives updates for ten seconds, sends heartbeats, and unregisters. It only reads engine data.

import json
import os
import time
import zmq

host, base_port = "127.0.0.1", 7000
password = os.environ["DARTWIC_PASSWORD"]
channel = "node_dartwic_cpu_usage_percent"
context = zmq.Context()
client_id = ""
subscriber = context.socket(zmq.SUB)
subscriber.setsockopt(zmq.LINGER, 0)
subscriber.setsockopt_string(zmq.SUBSCRIBE, "rapid/channels/" + channel)
subscriber.connect(f"tcp://{host}:{base_port + 1}")


def operation(name, payload):
    # A fresh socket per call also isolates replies arriving after a timeout.
    socket = context.socket(zmq.DEALER)
    socket.setsockopt(zmq.LINGER, 0)
    socket.setsockopt(zmq.SNDTIMEO, 3000)
    try:
        socket.connect(f"tcp://{host}:{base_port}")
        socket.send_json({
            "clientId": client_id,
            "name": name,
            "payload": payload,
            "timestamp": time.time_ns() // 1_000_000,
        })
        if not socket.poll(3000, zmq.POLLIN):
            raise TimeoutError(f"No response to {name}")
        response = socket.recv_json()
        if response["error"]:
            raise RuntimeError(response["payload"]["error"])
        return response["payload"]
    finally:
        socket.close()


try:
    registration = operation("register_client", {
        "serverPassword": password,
        "username": "raw-socket-example",
    })
    client_id = registration["clientId"]
    if not client_id:
        raise RuntimeError("Registration returned no client ID")
    initial = operation("dartwic/add-channel-to-telemetry", {
        "channel_name": channel,
    })
    print("Initial snapshot:", initial["snapshot"])

    deadline = time.monotonic() + 10
    next_heartbeat = time.monotonic() + 5
    while time.monotonic() < deadline:
        if time.monotonic() >= next_heartbeat:
            operation("heartbeat", {})
            next_heartbeat = time.monotonic() + 5
        if subscriber.poll(100, zmq.POLLIN):
            topic, body = subscriber.recv_multipart()
            message = json.loads(body)
            if topic.decode() == "rapid/channels/" + channel:
                print(message["payload"] if not message["error"] else message)
finally:
    try:
        if client_id:
            operation("unregister_client", {})
    finally:
        subscriber.close()
        context.term()

There are two subscriptions involved: SUBSCRIBE filters topic prefixes in your local ZeroMQ socket; dartwic/add-channel-to-telemetry asks the engine to produce a channel’s live stream for your session. One does not replace the other. The operation returns snapshot so you can initialize your display without waiting for a change. ZeroMQ subscription setup is asynchronous; PUB/SUB has no replay of updates missed before the subscription becomes active.

Use add-channels-to-telemetry with channel_names for a batch. Subscriptions are reference-counted: pair each add with remove-channel-from-telemetry, or unregister the session when finished. ZeroMQ topic filters match prefixes, so compare the received topic when you need an exact match. Topic filters are not per-client access controls; the PUB stream does not identify which subscriber requested a channel.

Use HTTP and WebSocket directly

HTTP uses the same registration and operation JSON. POST to http://host:7002/tempest/operation with Content-Type: application/json. TEMPEST operations do not use an HTTP Basic/Bearer login: serverPassword goes in the registration payload, and later requests carry the assigned clientId in their JSON body. A successful operation returns HTTP 200; an operation error returns HTTP 400 with the JSON error envelope. Read the error body instead of discarding it.

For example, this uses only Python’s standard library to register, read ARGUS events, and unregister:

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen

url = "http://127.0.0.1:7002/tempest/operation"
client_id = ""


def operation(name, payload):
    request = Request(url, method="POST", headers={
        "Content-Type": "application/json",
    }, data=json.dumps({
        "clientId": client_id, "name": name, "payload": payload,
        "timestamp": time.time_ns() // 1_000_000,
    }).encode())
    try:
        stream = urlopen(request, timeout=3)
    except HTTPError as error:
        stream = error  # TEMPEST supplies its error envelope in the HTTP body.
    with stream:
        response = json.load(stream)
    if response["error"]:
        raise RuntimeError(response["payload"]["error"])
    return response["payload"]


try:
    client_id = operation("register_client", {
        "serverPassword": os.environ["DARTWIC_PASSWORD"],
        "username": "raw-http-example",
    })["clientId"]
    print(operation("argus/get-active-events", {"limit": 1, "offset": 0}))
finally:
    if client_id:
        operation("unregister_client", {})

For a longer-lived HTTP application, send the same heartbeat operation periodically. Browser applications can use fetch with this envelope; use https and wss when your deployment provides TLS.

Attach a WebSocket telemetry connection

Register through HTTP first, then open ws://host:7002/tempest/telemetry. This socket carries telemetry JSON, not operation requests. Each incoming text message is one complete telemetry envelope; there is no separate topic frame.

The web transport associates the socket with your registered ID when it receives a telemetry message whose sender contains that ID. This association is needed for telemetry routed to selected clients, including live channels. The following first message uses an application-owned identification topic with no engine handler:

{
  "sender": "SERVER_ASSIGNED_ID",
  "topic": "my-app/identify",
  "error": false,
  "payload": {},
  "timestamp": 0
}

my-app/identify is a topic chosen by this example, not a built-in TEMPEST command. Association comes from the sender field. Send the message after the WebSocket opens, then request dartwic/add-channel-to-telemetry over HTTP with the same clientId. Process incoming messages by their topic, such as rapid/channels/node_dartwic_cpu_usage_percent. Keep HTTP heartbeats running; the identification message does not keep the session alive.

Closing the last identified WebSocket for a client unregisters that client in the current server, even if your HTTP code still has its ID. On a new session, register again, identify the new socket, and restore channel subscriptions. During graceful shutdown, stop heartbeat work, unregister, then close your sockets.

Call an operation

With a connected Python client:

response = client.operation("argus/get-active-events", {
    "limit": 100,
    "offset": 0,
})
response.raise_for_error()
for event in response.payload["events"]:
    print(event)

The client supplies connection and transport fields. Application code supplies the operation name and its payload. Operation reference pages describe the object inside payload, not an extra envelope you should nest around it.

Responses and errors

A successful wire response has this shape:

{
  "clientId": "client-identifier",
  "name": "argus/get-active-events",
  "error": false,
  "payload": { "events": [], "count": 0, "total_count": 0 },
  "timestamp": 0
}

Check error before reading the success payload. On failure it is true, and payload.error contains the server message. There is no universal success field. Raw requests contain clientId, name, payload, and timestamp; your implementation supplies these fields when you bypass a client library.

A timeout can occur without any response. It does not prove that the engine never accepted the operation. Inspect resulting state before retrying a command whose effects must not repeat.

Receive live values

Telemetry is pushed independently of request/response calls. Its envelope contains sender, topic, error, payload, and timestamp. A topic and an operation are different namespaces, even when their names are related.

For live channels, request the engine’s channel subscription and register the client-side topic handler; the React guide shows the wrapper’s subscription helper. Merely reading a local store does not subscribe it to new values.

The raw ZeroMQ telemetry endpoint is outbound-only. To send data to the engine, use an operation or the bidirectional web telemetry transport. Share’s frame operation and topic implement peer exchange; their transport name contains v2, independently of the Share protocol’s current codec version.

Telemetry envelope timestamps use Unix-epoch milliseconds. Recorded RAPID sample and historical-query timestamps use nanoseconds. Do not reuse an envelope timestamp as a history-query bound without conversion.

Detached jobs

Endpoints such as historical query/start return a job ID while work continues. A start response means the job was accepted, not that the data is ready.

OperationAction
tempest/job-statusSupply job_id; inspect state, progress, and the final result.
tempest/job-cancelRequest cancellation of a job owned by this client.

States are queued, running, completed, failed, and canceled. Poll status at an interval appropriate to the job, stop at a terminal state, and handle error on failure. Cancellation is a request; inspect the final state before assuming work stopped. Jobs are scoped to the connected client that owns them.

See Telemetry and History for recording and query choices, Execution and Timing for scheduling boundaries, and the telemetry reference for topic payloads.