Running an Agent
- How does the agent authenticate?
- Which sessions should it drive?
- How do you know data is flowing?
- How do you tune it for smooth updates?
This page covers the recommended configuration for running a production-ready ingestion agent.
Authentication
An agent needs two things: the scene to drive and a token to drive it with.
new Agent({ config: { scene_id: "...", token: "..." } });
The token must grant write access to the scene — see access control.
Keep tokens in the environment, not in the source:
const agent = new Agent({
config: {
scene_id: process.env.SCENE_ID!,
token: process.env.LIVELINK_TOKEN!,
},
});
Session Management
A scene can have several rendering sessions at once. The agent's mode decides which the agent drives:
| Mode | Behavior |
|---|---|
"join-or-start" (default) | Join an existing session, or create one if none exists |
"start" | Always create a new session |
"join" | Join a single existing session. Fails if none exists, unless watch is on — the agent then idles until one appears |
"join-all" | Join every existing session of the scene |
"manual" | Attach to nothing; join sessions on demand through agent.join() |
Most applications only need one of two configurations.
- One shared session —
"join-or-start". Everyone looking at the scene sees the same session, and the agent drives it whether it opened it or joined it. This is the default and the right choice for a digital twin with one canonical view. - Every session —
"join-all"withwatch. Each viewer gets its own session, and the agent drives them all from one subscription to the data source.
const agent = new Agent({
config: {
scene_id,
token,
mode: "join-all",
watch: { interval_seconds: 10 },
},
});
watch polls the session list and joins sessions as they appear. In "join" mode it only joins when the agent is not
already attached to one, which doubles as a reconnect mechanism after a connection loss.
To pin the agent to one specific session — the one your page just opened, say — use a selector:
config: {
scene_id,
token,
session_selector: ({ sessions }) => sessions.find(s => s.session_id === MY_SESSION_ID) ?? null,
}
Closing Idle Sessions
A rendering session costs money for as long as it is open, and an agent counts as a client — so an agent left running can keep a session alive with nobody watching it.
leave_on_condition makes the agent leave when a condition has not held for a while:
config: {
scene_id,
token,
mode: "join-all",
watch: { interval_seconds: 10 },
leave_on_condition: { after_seconds: 60 },
}
By default the agent stays while any other client is connected, and leaves a minute after the last one goes. A session
left this way is rejoined by the watch loop as soon as a viewer comes back; a session left deliberately
(agent.leave(), agent.stop()) is not.
Customize the decision with a predicate:
leave_on_condition: {
after_seconds: 60,
should_stay: ({ other_clients }) => other_clients.some(client => client.client_type === "user"),
}
Monitoring an Agent
The easiest way to verify that your ingestion is working is through ingestion.stats — an answer neither the transport
nor the scene can give you.
setInterval(() => {
const stats = ingestion.stats;
if (!stats) {
return;
}
console.log(
`received ${stats.events_received} applied ${stats.updates_applied} ` +
`written ${stats.components_written} deduped ${stats.components_deduped} ` +
`drops ${JSON.stringify(stats.drops)}`,
);
}, 5000);
| Counter | Meaning |
|---|---|
events_received | Events submitted to the pipeline, whatever became of them |
events_matched | Events at least one mapping selected |
events_dropped | Events no mapping applied anything for |
updates_applied | Entity updates successfully applied, per bound scene |
components_written | Component writes actually sent |
components_deduped | Writes skipped because the value was unchanged |
directives_applied | delete / hide / show carried out |
drops | Breakdown by reason — the useful one during bring-up |
last_event_at | When the pipeline last received anything |
bound_scene_count | Scenes currently bound |
per_mapping | The same counters, per mapping |
events_matched and events_dropped overlap: an event a mapping selected but applied nothing for counts in both.
Diagnosing with drops
This table is what turns "nothing is moving" into a specific cause:
| Reason | What it means | Usual fix |
|---|---|---|
no_binding | No scene bound yet — no session was ready when the event arrived | Normal at startup. Persistent means the agent never joined a session |
no_mapping_matched | Events arrive, but no mapping's channel / when selected them | Check the pattern against the real channel — print one event |
schema | The payload failed the mapping's JSON Schema | The stream does not carry what the mapping reads |
no_id | updates returned an update with no usable id | The id is not where the mapping looks for it |
no_updates | updates returned null — a deliberate ignore | Expected, if you meant it |
unresolved_entity | The id resolved to no entity in the scene | Name mismatch with byName, or a retained message arriving early |
events_received staying at zero means nothing reached the pipeline at all: the problem is upstream — credentials,
topics, firewall — not in your mapping.
Performance
Choosing an Update Rate
Applying a component update does not immediately send it to connected viewers. updateComponent flags the entity
dirty, and updates are flushed at a fixed frequency. By default:
- 30 updates per second
- maximum 125 updates per second
For smooth motion, configure the update rate comfortably above the frequency of your fastest-moving entities.
Update Persistence
Entity changes are persisted to the scene independently from the live update frequency. Use
headless_client.broadcastsPerSecond to configure the persistence rate — once a second by default.
Lifecycle Events
SceneIngestion re-emits the agent's session events alongside its own, so one object is enough to observe everything:
ingestion.addEventListener("on-running", () => console.log("Sources started"));
ingestion.addEventListener("on-session-bound", ({ livelink }) =>
console.log(`Driving session ${livelink.session.session_id}`),
);
ingestion.addEventListener("on-session-unbound", ({ livelink }) => cleanUp(livelink.session.session_id));
ingestion.addEventListener("on-session-left", event => console.log("Left:", event.reason));
on-session-bound is the one that matters for ingestion: from that event on, incoming events actually drive the scene.
Sources start lazily, on the first ready session, and are shared by every session — one subscription, one timeline. Until then, nothing is subscribed. If you need a source running before any viewer connects, own that transport yourself.
Error Handling
Errors split by layer, and it is worth pointing both at the same handler:
const pipeline = new IngestionPipeline({
mappings,
// A mapping threw, or an entity write failed.
onError: error => report(error),
});
const ingestion = new SceneIngestion({ agent, pipeline, sources });
// A source failed to start, or the agent errored.
ingestion.addEventListener("on-error", ({ error }) => report(error));
An on-error nobody listens for falls back to the console rather than vanishing. A source that fails to start is
retried by the next session to bind, so a broker that is down when the first session opens does not disable the
ingestion for good.
Stopping an Agent
const shutdown = async () => {
await ingestion.stop();
process.exit(0);
};
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
stop() stops the sources, leaves every session, and unbinds the scenes. There is no "stopped" event — stop() is
something you call, so do any post-stop cleanup after it returns.
An ingestion stays usable afterwards: start() attaches a fresh wave using the same configuration, and listeners
survive the cycle. Starting an already-started ingestion throws.
Using the Agent Directly
The ingestion layer is opt-in. Underneath it, Agent is datasource-agnostic: it attaches to sessions and hands you the
entity API and typed lifecycle events. Reach for it when the mapping model does not fit — most often because you need
to read out of the scene as well as write into it.
One thing the ingestion layer was doing for you stops here: it sets
auto_broadcast = false on every entity it resolves or
spawns. Without it, set it yourself on each entity you drive — otherwise the entity re-broadcasts writes the agent's own
update loop has already sent, which costs bandwidth and makes motion stutter.
import { Agent, type EntityUpdatedEvent } from "@3dverse/livelink-agent";
const agent = new Agent({ config: { scene_id, token } });
agent.addEventListener("on-session-ready", async ({ livelink }) => {
const entity = await livelink.scene.findEntity({ entity_uuid: "..." });
// For smooth animation, do not re-broadcast this entity's transform.
entity.auto_broadcast = false;
entity.updateComponent("local_transform", { position: [0, 1, 0] });
// Outbound: react to changes other clients make.
entity.addEventListener("on-entity-updated", (event: EntityUpdatedEvent) => {
if (event.isExternal()) {
publish(entity.local_transform);
}
});
});
await agent.start();
// ...
await agent.stop();
A single agent can be attached to several sessions at once — agent.livelinks is all of them, and each session event
carries its own event.livelink. Keep per-session state in a map keyed by event.livelink.session.session_id and
clear it on on-session-left.