npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@3dverse/livelink-agent

v0.5.7

Published

Headless agent package for controlling 3dverse rendering sessions programmatically (Node.js and browser compatible)

Downloads

457

Readme

@3dverse/livelink.agent

About

Headless agent package for controlling 3dverse rendering sessions programmatically (Node.js and browser compatible).

An agent is a headless client that attaches to one or more sessions of a scene and controls them through the entity API: create, update and delete entities, and react to changes made by other clients. Typical use case: bridging an external datasource (MQTT broker, OPC-UA server, WebSocket feed...) to live 3dverse scenes.

See the official documentation: Connect Live Data

Samples

Try it: Agent Data Ingestion Demo

Runnable examples:

Installation

npm install @3dverse/livelink-agent

Usage

For the common case — "an external event stream drives entities in the scene" — the package ships an opt-in ingestion layer. Its heavy dependencies (mqtt, @azure/event-hubs, node-opcua-client, ajv) are optional peers, loaded lazily by the transport or validator that needs them; not installing them is fine, and costs nothing at build or run time.

Two objects. An EventMapping — a plain object — says how one event type drives entities. An IngestionPipeline runs the mappings against every scene bound to it, and ingest is how events get in:

import { IngestionPipeline, type EventMapping } from "@3dverse/livelink-agent";

const mapping: EventMapping = {
  // Optional selectors: an MQTT-style pattern over the event's channel, and/or a payload predicate.
  channel: "devices/+/telemetry",
  // Optional JSON Schema: the FIRST matching event is validated against it (every event with the
  // pipeline's `validate: true` — a debugging tool).
  schema: { type: "object", properties: { pos: { type: "array" } }, required: ["pos"] },
  // Which scene entities the ids these events carry address — one of four strategies, detailed below.
  entities: {
    spawn: {
      name: "device-{id}",
      components: { local_transform: { position: [0, 0, 0] } },
      options: { delete_on_client_disconnection: true },
    },
  },
  // What one event does. Return one `{ id, update }`, an array of them when a single event carries
  // several objects, or null to ignore the event. `update` is a set of component patches,
  // "delete" / "hide" / "show" to act on the entity as a whole, or `continuous(...)` for
  // something that keeps moving between events (see below).
  updates: event => ({
    id: event.channel.split("/")[1], // the id can come from the channel, the payload, anywhere
    update: { local_transform: { position: (event.payload as { pos: [number, number, number] }).pos } },
  }),
};

const pipeline = new IngestionPipeline({ mappings: mapping }); // or an array
pipeline.bind({ scene });
await pipeline.ingest({ channel: "devices/42/telemetry", payload: { pos: [1, 2, 3] } });

Nothing above needs an agent, a session or a broker — which is what makes a mapping straightforward to unit-test, to drive from a webhook or a REST handler, and to replay one frame at a time.

Updates that keep going

Some events carry a rate, not a value — "the shaft is turning at 90 rpm". A rate still means something after the message that delivered it, so writing a finished patch would leave the entity frozen until the next message, which for a machine reporting only on change may be minutes away.

Wrap the update in continuous() and it keeps producing values until a later event for that id replaces it:

updates: event => {
  const { rpm } = event.payload as { rpm: number };
  const id = event.channel.split("/")[1];

  return {
    id,
    update: continuous<{ angle_deg: number }>(
      ({ delta_seconds, state }) => {
        // 360 degrees a turn, 60 seconds a minute.
        state.angle_deg = (state.angle_deg + rpm * 6 * delta_seconds) % 360;
        return {
          local_transform: {
            eulerOrientation: [state.angle_deg, 0, 0]
          }
        };
      },
      { initial_state: { angle_deg: 0 } },
    ),
  };
},

The sample is handed three things:

| | | | --------------- | ----------------------------------------------------------------------------------------- | | delta_seconds | Since the previous sample, 0 on the installing event. For a value that accumulates. | | since_seconds | Since this motion started. For a value that is a closed form of its age — a fade, a ramp. | | state | Scratch space belonging to the entity, not to this motion. |

state survives the event that replaces the continuation, so a new rpm picks the shaft up where it stands rather than snapping it back; initial_state therefore applies only the first time an entity is given one.

A motion is installed per (mapping, id) — not per channel, and not per session, so two sessions on the same scene turn the same shaft at the same speed. It runs until one of five things happens: the sample returns null, it throws, a later event for that id replaces it, it or an event produces "hide" or "delete", or you call pipeline.clearContinuations().

Nothing expires a motion on a timer, so a stream that dies leaves the scene moving: watch last_event_at against continuations_active (below) for that, and SceneIngestion calls clearContinuations() when it stops its sources.

updates returning null does not stop a motion. It means the message said nothing about that entity, which is what lets one topic carry payloads of several shapes. To stop a motion from an event, return continuous(() => null). A plain component patch does not stop one either — patch and motion are independent writes, so on a shared component the tick wins.

SceneIngestion runs the clock (ticksPerSecond, twice the client's flush rate by default, 0 to switch it off), redundant writes are still deduplicated so an entity holding still costs nothing, and ticks are not counted as events. Driving the pipeline yourself, pipeline.tick(elapsed_seconds) owns no timer, so a motion replays exactly from a test: await pipeline.ingest(event); await pipeline.tick(0.5);.

Addressing entities: the four strategies

| Strategy | How the id finds its entity | Linkage | Typical use | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------------------------------------- | | byName: "{id}" (string or ({id, event}) => string) | Looks up an entity already in the scene by name ({id} substituted, or computed) | Optional | The scene is already named after the stream's ids — no UUID configuration needed | | byUuid: { "servo-01": "<uuid>" } | A fixed, closed id → UUID table, looked up in the scene | Optional (per entry, via { entity_uuid, linkage }) | A small, known population (e.g. named parts of one machine) | | resolve: ({ id, event }) => ... | An arbitrary function returning a UUID, { entity_uuid, linkage }, or null — still resolves an existing entity, just computed dynamically | Optional | An external lookup service, or a naming convention with exceptions | | spawn: { name, components, options } | No pre-existing entity — one is created per new id, from a template, the first time that id is seen | Not accepted — always created at the scene root | New objects arriving over the stream (a device joining a fleet) |

The four are mutually exclusive — set exactly one per mapping (enforced by TypeScript's discriminated union; a mapping setting none throws at construction). Resolution is cached per id per scene: once an id resolves (or fails to), later events for that id reuse the result instead of re-running the strategy. A cached failure is retried automatically when the scene's own on-entities-created/on-entities-deleted events suggest the answer changed. spawn follows the same cache: the entity it creates is reused on every later event for that id, and only a fresh spawn happens after that entity is gone (deleted by this mapping's "delete" directive, or by another client) and a further event arrives for the same id. An id that resolves to nothing surfaces as unresolved_entity in ingestion.stats.drops (below).

Binding it to the sessions of an agent

SceneIngestion is the wiring: it binds each ready session's scene to the pipeline, unbinds it when the session goes, and starts the data sources on the first binding.

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

const ingestion = new SceneIngestion({
  agent: new Agent({ config: { scene_id, token } }),
  pipeline, // always yours to build — see above
  sources: [{ kind: "mqtt", config: { broker_url, topics: ["devices/#"] } }],
});

ingestion.addEventListener("on-error", ({ error }) => report(error));

await ingestion.start();
// ...events flow. You can push your own in at any time:
await ingestion.ingest({ channel: "devices/42/telemetry", payload });
await ingestion.stop();

Errors split by layer: the pipeline's onError reports a mapping that throws or an entity write that fails; the ingestion's on-error event reports a source that fails to start or an underlying agent error. Point both at the same handler for a single channel. An on-error nobody listens for falls back to the console rather than vanishing.

sources is optional, and SceneIngestion is itself an EventSink — so a transport you own can be pointed straight at it, and events from anywhere else can be pushed in with ingest.

Sources start lazily, on the first ready session, and are shared by every session. Until a session is ready nothing is subscribed; anything ingested with no scene bound is counted as a no_binding drop rather than lost silently.

Knowing whether data is flowing

const { events_received, updates_applied, components_written, drops, last_event_at } = ingestion.stats!;

drops breaks down by reason — no_binding, no_mapping_matched, schema, no_id, no_updates, unresolved_entity — which is what separates "the stream isn't arriving" from "it arrives but no mapping wants it" during bring-up. per_mapping carries the same counters mapping by mapping, plus continuations_active: with several mappings, that is what says which one is still driving something after its stream went quiet.

Counting is on by default: it allocates nothing and costs a few integer increments per event, well under a single component write. For the very highest-frequency streams, build the pipeline with stats: false — stats then reports null rather than zeros, so a disabled counter can never be misread as "nothing flowed".

SceneIngestion also emits on-running, on-session-bound / on-session-unbound, on-error, and re-emits the agent's own session events, so you never have to reach through .agent to observe it.

Transports

The bundled transports are mqtt, opcua, azure-event-hub and playback. Note that channel only means something on some of them: it is the topic on MQTT, the node id — or its alias — on OPC UA, the recorded topic on playback, and the partition id — a load-balancing artifact — on Azure Event Hubs, where you should select on the payload with when instead. See ARCHITECTURE.md for the full picture.

opcua subscribes to variables on an OPC UA server over opc.tcp:// — the classic client/server profile a PLC exposes — and publishes each value change as { node_id, value, status }. Raw node ids make unreadable channels, so alias them:

sources: [
  {
    kind: "opcua",
    config: {
      endpoint_url: "opc.tcp://plc.example.com:4840",
      nodes: [{ node_id: 'ns=3;s="DB_Line1"."Temperature"', channel: "plc/line1/temperature" }],
      publishing_interval: 500,
      security_mode: "SignAndEncrypt", // implies Basic256Sha256; omit for an unsecured endpoint
      username: "operator",
      password: process.env.PLC_PASSWORD,
    },
  },
];

Node.js only, opc.tcp being raw TCP. Samples whose status is Bad carry no meaningful value and are dropped rather than written into the scene, logging once per node instead of at every publication. The intervals are a request — the server answers with the pace it will hold to, and most refuse to publish faster than 50 ms — so a request revised upward is logged once too, rather than leaving a scene moving at half the configured rate with nothing to say why. A secured connection makes node-opcua generate a self-signed client certificate on first use, which the server has to be told to trust — on a Siemens PLC that is a manual step in its web interface, and the failure everyone hits first.

If the plant already bridges OPC UA to MQTT — OPC UA PubSub over MQTT on recent firmware, Telegraf's inputs.opcua, Kepware, Ignition — point mqtt at that broker instead: one hop fewer, and no OPC UA session to keep alive next to the scene. opcua is for the servers that offer no such bridge.

playback replays a recorded event stream from wherever it lives — a file, a URL, a string, bytes, a stream, or records you parsed yourself — so the same mapping can be brought up against a dump before it ever meets a broker, in Node or in the browser:

sources: [{ kind: "playback", config: { source: { url: "/recordings/devices.json" }, speed: 2 } }];

Only source: { file_path } needs Node. speed scales the recorded pace (default 1, real time) and loop (default true) starts the recording over when it ends. The former file kind is a deprecated alias.

For runnable examples, see the Samples section above.

Need the raw Agent API instead — no ingestion layer? See Using the agent directly.

Session modes

The mode option of the agent config selects how the agent attaches to sessions:

| 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 enabled, in which case the agent idles until one appears. | | "join-all" | Join all existing sessions of the scene. | | "manual" | attach to nothing on start; the agent stays idle and joins sessions on demand through its join method. |

An agent that loses its last session without being told to — it dropped, or the leave condition fired — has no way of getting more work, so it stops itself and dispatches on-stopped. A SceneIngestion stops its sources with it, which is what lets a single-session agent's process exit on its own. This does not apply while watch is enabled, in "manual" mode, or to a session left deliberately through leave() — that one can be rejoined with join().

Watching for sessions

With the watch option (valid in "join" and "join-all" modes), the agent polls the session list and joins sessions as they appear:

const agent = new Agent({
  config: {
    scene_id,
    token,
    mode: "join-all",
    watch: { interval_seconds: 10 },
  },
});
await agent.start();

In "join" mode, the watch loop only joins a session when the agent is not attached to one (this also serves as a reconnect mechanism after a connection loss).

Leaving on condition

With the leave_on_condition option, the agent leaves any session if a condition is not met for the given duration:

const agent = new Agent({
  config: {
    scene_id,
    token,
    mode: "join-all",
    watch: { interval_seconds: 10 },
    leave_on_condition: { after_seconds: 60 },
  },
});
await agent.start();

By default, the agent stays while any other client is connected. To make it ignore other agents — so a session occupied only by agents still closes — give it an agent_roster: a well-known entity under which each agent registers a marker entity named after its client id (created automatically on join, with delete_on_client_disconnection). When agent_roster_id is set, the agent stays only while a client without a matching marker (a real viewer) is present, instead of relying on the unreliable is_headless flag. If the entity is not found in the scene, an error is logged and the check falls back to plain other-client presence.

leave_on_condition: {
    after_seconds: 60,
    agent_roster_id: "d577efd3-cca8-41ca-a58e-27e944f7b5de",
}

Customize the decision with the should_stay predicate, which receives the session's livelink and the other clients:

leave_on_condition: {
    after_seconds: 60,
    should_stay: ({ livelink, other_clients }) => other_clients.some(c => c.client_type === "user"),
}

Note that a Client seen by an agent has identity only — id, user_id, username, client_type, is_external. What a client shows (the camera entities it views the scene through, the entity under its mouse pointer) travels in the client metadata piggybacked on the video frames, which an agent never receives; those members live on the browser SDK's Client. To reach a viewer's own entities from an agent, have the viewer publish what it wants the agent to know into the scene, or key the state you keep on client.id.

A session left on condition is only rejoined by the watch loop once the condition is met again. A session left deliberately (agent.leave() or agent.stop()) is never rejoined.

Note: right after joining a session, the client list is populated asynchronously. The leave timer may arm immediately and is simply cleared as soon as the condition is met — with timeouts in seconds this is harmless.

Lifecycle events

A single Agent instance can be attached to several sessions at once, and dispatches its lifecycle events on itself. Each session event carries its Livelink directly as event.livelink. To keep per-session state, hold your own map keyed by event.livelink.session.session_id and clean it up on on-session-left.

// The agent created this session: seed the scene state.
agent.addEventListener("on-session-created", event => {});

// The agent joined a pre-existing session.
agent.addEventListener("on-session-joined", event => {});

// Always emitted, after on-session-created or on-session-joined.
agent.addEventListener("on-session-ready", event => {});

// The agent left a session. event.reason is "left-on-condition" | "disconnected" | "stopped".
agent.addEventListener("on-session-left", event => console.log("left", event.reason));

// An error occurred (failed join, failed session list poll...). event.livelink is null
// when the error is not tied to an established session.
agent.addEventListener("on-error", event => console.error(event.error));

There is no "stopped" event: agent.stop() is something you call, so do any post-stop cleanup after await agent.stop() returns.

Note that on-session-ready does not mean every entity of the scene is addressable: a scene pulling others in through scene_ref components is streamed in progressively, and entities living in those referenced scenes do not exist server-side until the server says it is done. await livelink.scene.waitForSceneLoaded() before looking one up — the ingestion layer above already does it for you, before resolving anything. It resolves true once the scenes are loaded and false if the session disconnected first, and never throws. Note that it has no timeout: a scene whose reference the token cannot read never finishes loading, and the wait then only ends on disconnection, so race it against a deadline of your own if you cannot afford to be parked.

Using the agent directly

This section covers the Agent API directly, without the ingestion layer above — reach for it when a mapping doesn't fit, or you already have an ingestion layer of your own.

Basic usage

import { Agent } from "@3dverse/livelink-agent";

const agent = new Agent({
  config: {
    scene_id: "your-scene-id",
    token: "your-token",
  },
});

agent.addEventListener("on-session-ready", async event => {
  const entity = await event.livelink.scene.findEntity({ entity_uuid: "..." });
  entity?.updateComponent("local_transform", { position: [0, 1, 0] });
});

await agent.start();

By default the agent joins an existing session of the scene, or creates a new one if none exists ("join-or-start" mode).

auto_broadcast

For a smooth real time animation, you want to set Entity.auto_broadcast to false e.g entity.auto_broadcast = false in the upper code snippet before starting to animate the entity. Or, if you create the entity from the agent, you can set it in the options:

import { Agent } from "@3dverse/livelink-agent";

const agent = new Agent({
  config: {
    scene_id: "your-scene-id",
    token: "your-token",
  },
});

agent.addEventListener("on-session-ready", async event => {
  const entity = await event.livelink.scene.newEntity({
    name: `Animated object`,
    components: {
      local_transform: "default",
    },
    options: {
      auto_broadcast: false,
      delete_on_client_disconnection: true,
    },
  });
});

await agent.start();

The ingestion layer's IngestionPipeline already does this automatically for every entity it resolves or spawns (manage_auto_broadcast: true by default) — set manage_auto_broadcast: false on the pipeline to manage it yourself instead.

Wiring a datasource by hand

The ingestion layer above is the shortest path. Underneath it, the package stays datasource-agnostic: the entity API and the typed events are the integration surface, and you can wire any messaging system (MQTT, a WebSocket feed, a serial bus...) against a raw agent yourself:

import { Agent, Livelink, EntityUpdatedEvent } from "@3dverse/livelink-agent";
import mqtt from "mqtt"; // or any other client

const agent = new Agent({ config: { scene_id, token } });
const client = mqtt.connect("mqtt://broker.example.com");

async function applyUpdate(livelink: Livelink, entity_uuid: string, position: [number, number, number]) {
  const entity = await livelink.scene.findEntity({ entity_uuid });
  // For smooth animation, do not broadcast the entity's transform to other clients.
  entity.auto_broadcast = false;
  entity?.updateComponent("local_transform", { position });
}

// Inbound: datasource messages -> entity updates, in every attached session.
client.on("message", (topic, payload) => {
  const { entity_uuid, position } = JSON.parse(payload.toString());
  for (const livelink of agent.livelinks) {
    void applyUpdate(livelink, entity_uuid, position);
  }
});
client.subscribe("devices/+/position");

agent.addEventListener("on-session-ready", async event => {
  // Outbound: entity changes made by other clients -> datasource messages.
  const entity = await event.livelink.scene.findEntity({ entity_uuid: "..." });
  entity?.addEventListener("on-entity-updated", (event: EntityUpdatedEvent) => {
    if (event.isExternal()) {
      client.publish("scene/updates", JSON.stringify(entity.local_transform));
    }
  });
});

await agent.start();

// ...later, on shutdown: stop the agent, then close the datasource once it has
// left every session.
await agent.stop();
client.end();

Updates made through updateComponent() are batched and sent to the server automatically (30 updates/s, persisted once per second by default — tune with the headless_client option of the agent config).

Restarting an agent

agent.stop() leaves every session and halts the watch loop, but the agent object stays usable: agent.start() attaches a fresh wave using the same config, and listeners added with agent.addEventListener survive the cycle. Starting an already-started agent throws.