Skip to main content
8 min read

Quickstart

Get your first real-time data integration running.

In this tutorial you'll replay a recorded event stream to drive a 3dverse scene. Once everything works, replacing the recording with MQTT, OPC UA, Azure Event Hubs, or another live source only requires changing the transport configuration.

What you need

  • Node.js 18 or later
  • A 3dverse scene — it can stay empty, the example creates the entities it drives
  • A cube mesh asset in the same project, so those entities have something to display
  • A token with access to the scene

The scene ID, the mesh UUID and the token all come from the 3dverse Console. See access control for what a token grants.

The agent below runs on Node.js, but the package also runs in the browser — see where each transport runs.

Step 1 — Install

npm install @3dverse/livelink-agent

See the Livelink Agent API reference for complete documentation.

This tutorial uses the built-in playback transport, so no additional packages are required.

Step 2 — Define How Events Update the Scene

An Event Mapping describes how incoming events affect your scene. For each event it answers three questions:

  • Which events should be processed?channel, when
  • Which entity should be updated?entities
  • What should be written to that entity?updates
ingest.ts
import type { EventMapping } from "@3dverse/livelink-agent";

// The UUID of the cube Mesh asset you copied from the Asset Browser.
const CUBE_MESH_ID = process.env.MESH_ID!;

// An Event Mapping describes how incoming events affect your scene.
// API reference: /references/livelink.agent/type-aliases/EventMapping

const telemetryMapping: EventMapping = {
// Only handle events on this topic pattern. `+` matches one segment.
channel: "devices/+/telemetry",

// No entity exists yet: create one per device id, the first time we see it.
entities: {
spawn: {
name: "device-{id}",
components: {
local_transform: { position: [0, 0, 0] },
mesh_ref: { value: CUBE_MESH_ID },
},
options: { delete_on_client_disconnection: true },
},
},

// What one event does. The device id comes out of the topic.
updates: event => {
const { pos } = event.payload as { pos: [number, number, number] };
return {
id: event.channel.split("/")[1],
update: { local_transform: { position: pos } },
};
},
};

The first time a device appears, an entity is created automatically. Every subsequent event simply updates its position.

What {id} is

updates returns an id alongside what to write. That id is how the mapping knows one device from another — here it is the second segment of the topic, so an event on devices/dev-01/telemetry has the id dev-01.

spawn uses that same id: {id} in name is a placeholder replaced with it, so the two devices in the recording become entities named device-dev-01 and device-dev-02. An id is only ever spawned once — the first event for dev-01 creates the entity, and every later event resolves to that same entity and moves it.

Nothing in the code declares how many devices there are: the stream does. A broker with fifty devices publishing on that topic pattern produces fifty entities, with this mapping unchanged.

delete_on_client_disconnection: true means the entities vanish when the agent stops, leaving the scene as it was found — convenient while you iterate.

Step 3 — Replay Sample Data

playback replays a dump of an event stream. The dump can live in a file or at a URL, but it can equally be an array you already have in memory, which is all we need here:

ingest.ts
const RECORDING = [
{ channel: "devices/dev-01/telemetry", timestamp: "2026-01-01T00:00:00.000Z", payload: { pos: [0, 1, 0] } },
{ channel: "devices/dev-02/telemetry", timestamp: "2026-01-01T00:00:00.000Z", payload: { pos: [2, 1, 0] } },
{ channel: "devices/dev-01/telemetry", timestamp: "2026-01-01T00:00:00.500Z", payload: { pos: [0, 1.5, 0] } },
{ channel: "devices/dev-02/telemetry", timestamp: "2026-01-01T00:00:00.500Z", payload: { pos: [2, 0.5, 0] } },
{ channel: "devices/dev-01/telemetry", timestamp: "2026-01-01T00:00:01.000Z", payload: { pos: [0, 1, 0] } },
{ channel: "devices/dev-02/telemetry", timestamp: "2026-01-01T00:00:01.000Z", payload: { pos: [2, 1, 0] } },
];

Messages are paced from their own timestamps, and the recording loops when it ends — so those six events make two cubes bob up and down indefinitely.

Step 4 — Connect Everything

Create an Agent, a Pipeline, and a SceneIngestion. SceneIngestion joins the sessions of a scene, binds each one to the pipeline, and starts the sources:

ingest.ts
import { Agent, IngestionPipeline, SceneIngestion } from "@3dverse/livelink-agent";

const ingestion = new SceneIngestion({
// The agent attaches to the scene's sessions.
agent: new Agent({
config: {
scene_id: process.env.SCENE_ID!,
token: process.env.TOKEN!,

// Join the session you already have open, or start one if there is none.
mode: "join-or-start",

// Sessions this agent starts are transient: nothing it writes is persisted,
// so a public token is enough to drive them.
is_transient: true,

// A session costs money for as long as it is open. Leave one minute after
// the last viewer disconnects, rather than holding it open for nobody.
leave_on_condition: { after_seconds: 60 },
},
}),

// The engine running your mappings.
pipeline: new IngestionPipeline({
mappings: telemetryMapping,
onError: error => console.error("[mapping]", error),
}),

// Where the events come from.
sources: [{ kind: "playback", config: { source: RECORDING } }],
});

ingestion.addEventListener("on-error", ({ error }) => console.error("[ingestion]", error));
ingestion.addEventListener("on-session-bound", ({ livelink }) =>
console.log(`Driving session ${livelink.session.session_id}`),
);

await ingestion.start();

Step 5 — Run It

SCENE_ID=<your-scene-id> MESH_ID=<your-cube-mesh-id> TOKEN=<your-token> npx tsx ingest.ts

Open the scene in the console — or in your own app — and you should see two cubes appear and start moving. The agent joined the session you are looking at, or created one you can join.

Step 6 — Replace Playback with Live Data

This is the payoff:

  • The Event Mapping doesn't change.
  • The Agent doesn't change.
  • The Pipeline doesn't change.
  • Only the transport changes.
// npm install mqtt
sources: [
{
kind: "mqtt",
config: {
broker_url: "mqtt://broker.example.com:1883",
topics: ["devices/+/telemetry"],
},
},
],

Because playback preserves the channel each message was recorded on, a mapping that selected "devices/+/telemetry" in the replay selects exactly the same events live. That is what makes recording a few seconds of a real stream the fastest way to build a mapping.

The same one-line swap works for OPC UA, Azure Event Hubs, or a transport you write yourself.

Stopping the Agent

process.on("SIGINT", async () => {
await ingestion.stop();
process.exit(0);
});

stop() stops the sources, leaves every session, and unbinds the scenes, in that order.

Next steps

  • Mapping events to entities — addressing entities that already exist, handling events that carry several objects at once, deleting and hiding.
  • Data sources — what each transport installs, where it runs, and what it puts in an event's channel.
  • Running an agent — which sessions to join, and the update rate that decides whether the motion looks smooth.