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 |
ticks_processed | Clock ticks that advanced continuous updates |
continuations_active | Updates that keep going currently installed |
per_mapping | The same counters, per mapping — plus its own continuations_active |
events_matched and events_dropped overlap: an event a mapping selected but applied nothing for counts in both.
A clock tick is deliberately not an event: it leaves events_received, events_matched and last_event_at
untouched, so those keep answering "is data still arriving?" even while continuous updates drive the scene on their own.
components_written does count clock-driven writes, because they are writes. When the scene has stopped moving but the
stream looks healthy, continuations_active is the counter to read.
Read the other way round, the same pair is the one check worth building an alert on: last_event_at going stale while
continuations_active stays up means the scene is still showing motion on a rate nothing has confirmed since. Nothing
expires a motion on its own — see what happens when the stream
dies — so this is the signal, and
pipeline.clearContinuations() is the response. With more than one mapping in play, per_mapping[i].continuations_active
says which one is still driving something.
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. updateComponent writes the value onto the entity and flags
it, and a timer flushes everything flagged — 30 times a second by default, configurable through
headless_client.updatesPerSecond.
Only the last value written between two flushes is sent. So the question is whether a flush falls between two samples of the same entity, and that has an exact answer: set the update rate at or above the rate at which you write a single entity. At that point every sample gets a flush of its own, whatever the phase between the two timers.
Below it, the loss is not occasional but arithmetic — a source running at rate S against a slower flush rate U
delivers U/S of its samples. A 40 Hz stream against the default 30 loses exactly a quarter.
config: {
scene_id,
token,
// Each cell publishes at 40 Hz, so anything from 40 up delivers every sample.
headless_client: { updatesPerSecond: 60 },
}
The rate to compare against is per entity, not the total: five entities each driven at 40 Hz need 40, not 200. Each entity coalesces on its own.
Rates outside (0, 125] throw a RangeError rather than being clamped, and the throw fails the agent's join, so a
bad rate is never silently absorbed. Note also that a timer cannot go faster than its host resolves — if the loop
cannot keep the rate you asked for, the client says so on the console once.
Update Persistence
Entity changes are persisted to the scene, and reach clients other than the agent, on a second timer —
headless_client.broadcastsPerSecond, once a second by default. That default is why an entity driven by an agent
can look smooth in the render stream and yet lurch once a second in another client.
Only entities with auto_broadcast on take part, and the ingestion pipeline turns it off on everything it drives
(see broadcast is handled for you) — so raising this
rate costs nothing unless you have deliberately opted entities back in.
headless_client: {
updatesPerSecond: 60,
broadcastsPerSecond: 20,
}
It never pays to set it above updatesPerSecond: the persist list is filled by the update flush, so a faster broadcast
loop finds nothing to send on most of its ticks.
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.