Skip to main content
11 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
per_mappingThe 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:

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 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.