Skip to main content
8 min read

Sample Agents

Two runnable agents, each driving a 3dverse scene from infrastructure you start yourself with one Docker command. They are the concrete counterpart to this section: the same mappings and the same pipeline, against a live source rather than a snippet.

The two samples

SampleSourceWhat it shows
mqtt-ingestiona Mosquitto broker fed by mqtt-simwildcard fan-out — one mapping, six entities, identity in the topic
opcua-ingestionMicrosoft's iot-edge-opc-plcone signal per part of a machine cell, spawned into an empty scene

MQTT Ingestion builds a plant floor: two production lines of three machine cells, each publishing motor telemetry at 40 Hz and a status every few seconds. Nothing is authored ahead of time and no list of cells exists anywhere in the code — the topic a message arrives on says which cell it is about, which is what lets one mapping drive any number of entities. Add a seventh cell to the simulator and a seventh machine appears in the scene with nothing to change.

OPC UA Ingestion goes the other way: a handful of deterministic signals — a rising counter, two sine waves carrying injected anomalies, a toggling boolean — each mapped onto one part of a single machine cell the agent spawns itself. It also shows two mappings selecting the same node, and whole-entity show / hide directives.

Why there are two

The split between them is the one that matters in practice. MQTT is web-native, so those mappings run in a page over ws:// exactly as they do in the sample over mqtt://. opc.tcp:// is raw TCP and only ever runs in Node — which is also where an OPC UA client belongs in a real deployment: next to the PLC, on the plant network, with the scene as its only outbound connection.

See where each transport runs for the full picture.

Setup

The samples live in the livelink repository, under livelink.clients/livelink.agent/samples. They are a standalone package, deliberately outside the repository's workspaces, so running them takes one explicit install:

npm -C ../.. run build:agent   # the samples compile and run against the build output
npm install # from the samples directory
cp .env.example .env # then put your token in it

LIVELINK_TOKEN is the only required variable. LIVELINK_API_KEY is optional and used for one thing: opening the 3dverse editor on the session automatically once the agent starts driving it. Everything else has a default, listed in the header comment of each script.

Running the MQTT sample

Two terminals, from the samples directory:

docker compose -f mqtt-ingestion/docker-compose-mosquitto.yml up
npm run mqtt

Check that data is flowing before blaming a mapping — this is always worth doing first:

docker compose -f mqtt-ingestion/docker-compose-mosquitto.yml exec broker mosquitto_sub -t 'livelink-demo/#' -v

You should see a fast stream of livelink-demo/plant/line-a/1/motor {"rpm":915,"temp_c":45.4} lines.

Standing up your own broker

The three files below are what the sample's compose stack is made of. Copy them into a directory of your own if you want a broker and a plant floor publishing into it without cloning the samples.

mosquitto.conf
# Two listeners on the same broker: the simulator publishes over plain TCP, and a
# browser can only speak MQTT over WebSocket.
listener 1883
protocol mqtt

listener 8000
protocol websockets

# A throwaway broker on your own machine: no accounts, no TLS. Do not carry this
# line over to anything reachable from outside it.
allow_anonymous true
mqtt-sim.toml
config_version = 1

[brokers.local]
host = "localhost"
port = 1883

[clients.plant]
broker = "local"
id = "livelink-mqtt-sim"

# Motor telemetry. `expand` is cartesian, so these two variables produce six
# independent streams — and six entities in the scene.
[[streams]]
name = "motor"
client = "plant"
topic = "livelink-demo/plant/${line_id}/${cell_id}/motor"
every = "25ms"
mode = "fixed-rate"

[streams.expand]
line_id = { list = ["line-a", "line-b"] }
cell_id = { range = [1, 3] }

[streams.payload.json]
rpm = { walk = { type = "int", min = 400, max = 1800, step = 15, start = 900 } }
load = { random = { type = "float", min = 0.1, max = 1.0, precision = 2 } }
temp_c = { walk = { type = "float", min = 20, max = 95, step = 0.375, start = 45 } }

# Machine status. Retained, so a subscriber that connects mid-run is told the
# state of every cell immediately.
[[streams]]
name = "status"
client = "plant"
topic = "livelink-demo/plant/${line_id}/${cell_id}/status"
every = "4s"
qos = 1
retain = true

[streams.expand]
line_id = { list = ["line-a", "line-b"] }
cell_id = { range = [1, 3] }

[streams.payload.json]
state = { pick = ["running", "idle", "fault"] }
docker-compose.yml
services:
broker:
image: eclipse-mosquitto:2
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
ports:
- "1883:1883" # plain TCP — what the simulator and the agent both use
- "8000:8000" # WebSocket — for a page of your own on the same broker
healthcheck:
test: ["CMD-SHELL", "mosquitto_pub -h 127.0.0.1 -p 1883 -t __healthcheck -m ok -q 0 >/dev/null 2>&1"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
restart: unless-stopped

sim:
image: ghcr.io/marcelo-6/mqtt-sim:latest
volumes:
- ./mqtt-sim.toml:/work/mqtt-sim.toml:ro
command: ["run", "-c", "/work/mqtt-sim.toml"]
# Inside the broker's network namespace, so `localhost` in the TOML is the broker.
network_mode: "service:broker"
depends_on:
broker:
condition: service_healthy
restart: unless-stopped

The publishing interval is the ceiling on how smooth the scene can look: entities only move when a sample arrives, so at a more typical 100ms this is a visible 10 Hz staircase. 25ms is 40 Hz, and the step values are a quarter of what they would be at 100ms so the signals still travel at the same speed per second. Change one, change the other.

Running the OPC UA sample

The server is Microsoft's simulated PLC, so no PLC of your own is needed:

docker run --rm -it -p 50000:50000 -p 8080:8080 --name opcplc \
mcr.microsoft.com/iotedge/opc-plc:latest \
--pn=50000 --autoaccept --unsecuretransport --ct=50 --sc=100
npm run opcua

--ct=50 runs the simulation at 20 Hz rather than the default 10 Hz, so the motion is smooth. It has to match the sample's OPCUA_CYCLE_MS — change one and change the other. Everything else is stock, and every node the agent reads is one opc-plc publishes by default.

Reading the output

Both samples print their pipeline counters every 5 seconds, and only when something moved — a silent source leaves the transport's own reconnection messages legible instead of burying them under empty counters. Ctrl-C stops the agent, and the entities it spawned go with it, because they were created with delete_on_client_disconnection.

Those counters are the first place to look when nothing appears in the scene: drops tells you whether the events never arrived, arrived but matched no mapping, or matched but resolved to no entity. See monitoring an agent for what each reason means.

Next steps

  • Mapping events to entities — the four ways to address entities, whole-state frames, and directives for entities that come and go.
  • Data sources — the same mappings, fed from Azure Event Hubs, a recording, or a transport you write yourself.
  • Running an agent — session modes, closing idle sessions, and the update rate that decides whether the motion looks smooth.