Skip to main content
14 min read

Running an Agent

Once your mapping is working, the remaining questions are operational:
  • 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:

ModeBehavior
"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" with watch. 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);
CounterMeaning
events_receivedEvents submitted to the pipeline, whatever became of them
events_matchedEvents at least one mapping selected
events_droppedEvents no mapping applied anything for
updates_appliedEntity updates successfully applied, per bound scene
components_writtenComponent writes actually sent
components_dedupedWrites skipped because the value was unchanged
directives_applieddelete / hide / show carried out
dropsBreakdown by reason — the useful one during bring-up
last_event_atWhen the pipeline last received anything
bound_scene_countScenes currently bound
ticks_processedClock ticks that advanced continuous updates
continuations_activeUpdates that keep going currently installed
per_mappingThe 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:

ReasonWhat it meansUsual fix
no_bindingNo scene bound yet — no session was ready when the event arrivedNormal at startup. Persistent means the agent never joined a session
no_mapping_matchedEvents arrive, but no mapping's channel / when selected themCheck the pattern against the real channel — print one event
schemaThe payload failed the mapping's JSON SchemaThe stream does not carry what the mapping reads
no_idupdates returned an update with no usable idThe id is not where the mapping looks for it
no_updatesupdates returned null — a deliberate ignoreExpected, if you meant it
unresolved_entityThe id resolved to no entity in the sceneName 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.