JS SDK
This page is the JavaScript API reference. Follow Integrate into a full-stack app for one complete identity, streaming, reconnect, and feedback workflow.
Install
npm install @introspection-sdk/introspection-nodeNode.js 22 or later. Add @introspection-sdk/introspection-browser for a web
application. The @introspection-ai/cli is a separate package with
a higher floor — its toolchain requires Node.js 24+ — so the SDK running on 22
does not imply the CLI will.
Follow the application lifecycle
For a normal product integration, read these sections in this order:
- Run a task and retain its task and run IDs.
- Continue, control, and reconnect without duplicating work.
- Read the durable conversation after the turn settles.
- Record feedback against the exact conversation or response.
Files, tags, connectors, and experiments are optional capabilities. Add them only when the application workflow requires them. Browser callers should also read Browser applications before choosing an entrypoint.
Run a task
client.runtimes(name).run() opens a runner: one session against a deployed runtime. The client reads INTROSPECTION_TOKEN when you do not pass a token.
import {
EventType,
IntrospectionClient,
} from "@introspection-sdk/introspection-node";
const client = new IntrospectionClient();
const runner = await client.runtimes("support-agent").run({
identity: { user_id: "user_123" },
});
const handle = await runner.tasks.start({
prompt: "Summarize my open tickets.",
});
for await (const event of handle.stream()) {
if (event.type === EventType.TEXT_MESSAGE_CONTENT) {
process.stdout.write(event.delta ?? "");
}
}run() accepts identity (any of user_id, anonymous_id, and conversation_id, plus tags — see Tags), caller, agent_name, ttl_seconds (session lifetime; the server default is one hour), and scope. The runner exposes six API clients: tasks, files, conversations, events, metrics, and shares, plus runner.context, a read-only accessor holding what the run resolved to: runtime, recipe, experiment arm, identity, and the echoed caller.
Every example below keeps using this runner. When the work is finished, release it with await runner.close() and then await client.shutdown(). Closing is local and final: a call through any of the six clients afterwards throws RunnerExpiredError, so open a new runner rather than reusing a closed one.
Call await handle.text() instead of iterating when you only want the finished answer. Continue the same task with a follow-up run:
const followUp = await runner.tasks.runs.create(handle.run.task_id, {
kind: "prompt",
prompt: { text: "Now draft the reply." },
});
console.log(await followUp.text());Use kind: "steer" to redirect a run that is still in progress.
Continue and control a task
stream() reconnects transparently across readiness waits and short disconnects, so a mid-turn network blip is not visible to the consumer. A RunHandle only lives in memory: save task_id and the run id when a user may come back while work is still running, then reattach.
for await (const event of runner.tasks.runs.stream(taskId, runId, {
signal: controller.signal,
})) {
render(event);
}stream() also takes maxReconnects (default 5), backoffMs (default 500), timeoutMs (default 300000), and emitReconnectEvents (default false), which surfaces each recovery as an AG-UI CUSTOM event named introspection.reconnect.
An agent pauses when it needs an answer, for example approval for a tool call. The run finishes with an interrupt outcome, and you continue it with a resume run:
let interrupts = [];
for await (const event of handle.stream()) {
if (event.type === EventType.RUN_FINISHED && event.outcome?.type === "interrupt") {
interrupts = event.outcome.interrupts;
}
}
const resumed = await runner.tasks.runs.resume(handle.run.task_id, {
resume: [{ interruptId: interrupts[0].id, status: "resolved", payload: { approved: true } }],
});Each entry takes status: "resolved" | "cancelled" and an optional payload.
Stopping iteration locally does not stop the agent — cancel the run explicitly, either on the handle or with runner.tasks.runs.cancel(taskId, runId, options). TaskCancelOptions defaults to mode: "abort", which interrupts the turn and keeps the sandbox warm; mode: "drain" lets the turn settle first, and drain_within_seconds forces teardown after that bound.
await handle.cancel(); // abort now
await handle.cancel({ mode: "drain", drain_within_seconds: 60 });Read what happened
After the turn settles, read the durable conversation associated with the task instead of treating the stream as the record. Preserve the task and conversation IDs so reconnects, exports, and later evidence all point to the same work. The complete read surface is in Conversation, event, and metrics reference.
Record feedback
Record the user’s outcome against that exact conversation or response. This is what joins product behavior back to the agent work that produced it. Use Feedback API reference for backend and browser examples.
Optional capabilities
Skip this group on a first integration. Add only the capabilities your product needs, then continue to Read what happened.
Warming an interactive task
An interactive application can hide sandbox startup behind the time a user spends composing their first message:
- When the user shows intent to chat, establish the session and create a task without a prompt. Creating it is what starts the sandbox, so the warming has already begun here.
- When the user submits, adopt that task, update its placeholder title or metadata, and open the first
promptrun. - If the draft is abandoned, archive the unbound task. Track unfinished cleanup locally so a later page load can retry it.
A bodyless run request is only needed to restart a task that has already finished. On a task created this way it is a no-op that reports status.
// Begin while the user is composing. Creating the task provisions its sandbox,
// so the wait overlaps with typing. `warmup` is application metadata here, not
// a platform switch.
const runner = await client.runtimes("support-agent").run({
identity: { user_id: applicationUser.id },
});
const created = await runner.tasks.create({
agent_name: "agent",
title: "Preparing chat",
idle_timeout_seconds: 600,
metadata: { warmup: true },
});
const taskId = created.task.id;
// Adopt the warm task when the user sends their first message.
await runner.tasks.update(taskId, {
title: "Research the account",
metadata: { warmup: false },
});
const run = await runner.tasks.runs.create(taskId, {
kind: "prompt",
prompt: { text: userMessage },
});Warm only a fresh, unbound draft. An already selected task has its own lifecycle; do not provision it merely because its composer receives focus. Coalesce repeated draft signals and impose a freshness window so typing, focus, and navigation do not create duplicate tasks.
Warmup is an optional latency optimization, not a separate task type. Keep the normal path as a fallback: if creation or provisioning fails, create the task when the user submits and start the prompt normally.
Resumable streams
A run’s live stream can be severed (a network blip, a gateway idle timeout)
without losing anything. stream() re-attaches for you with the last event id
it saw (the SSE Last-Event-ID header) and the run replays its recent frames;
anything older than the replay window can be hydrated from the task’s
conversation record, deduplicating by item id.
Attaching to a run that has not started yet holds the connection open, emitting
lifecycle frames until the run is live. Pass wait_for_start=0 when you would
rather poll than hold: the server waits for the advertised retry window and
then, if the run has still not begun booting a sandbox, returns 429 with
Retry-After and the run’s current phase in the body. A run that is already
booting holds the connection and emits lifecycle frames regardless, since the
wait is bounded by the boot rather than open-ended. On the task’s active run the
connection is held either way, so a turn is never dropped between attaching and
starting.
You do not handle that 429 yourself. stream() treats it as a readiness
wait, not a failure: it honors Retry-After as the backoff floor and
re-attaches, bounded by timeoutMs rather than maxReconnects — a run that is
slow to provision has not failed at anything. Only when the deadline lapses
does the RateLimitError surface.
Files and shares
Upload bytes first, then attach the file id to the task or run that should receive it. The agent gets a path in its workspace, not the contents, so a large attachment costs no tokens until it is read.
const spec = await runner.files.upload({
file: new Uint8Array(buffer),
name: "spec.md",
});
const withFile = await runner.tasks.start({
prompt: "Read spec.md and summarize it.",
files: [{ id: spec.id }],
});
const bytes = await runner.files.download(spec.id);files.createText({ name, content, mime_type }) writes a text file directly, and files also provides list, get, update, delete, and downloadStream. files.versions is a nested client of its own, with list, get, and create for a file’s version history. Pass name in a files entry to mount the file at a different workspace path, and pass files to tasks.runs.create() to attach one mid-conversation.
A share is a read-only grant on a file or conversation. Pass granted_member_id to target one member, or omit it for a project-wide read grant — an end customer is a member, so there is no separate identity target. The conversation id comes off the conversations list (or off any span you already hold):
const { records } = await runner.conversations.list({ limit: 1 });
const conversationId = records[0].id;
const share = await runner.shares.create({
resource_type: "conversation",
resource_id: conversationId,
granted_member_id: memberId,
});
console.log(share.url);
await runner.shares.delete(share.id);Pass a share id as fork_share_id to tasks.create() to start a new task seeded from a shared conversation.
Tags
Tags group tasks and, because access is set intersection, share them with a cohort of members. Set them on create, replace them on update, filter with them on list, and read them back off the task.
const handle = await runner.tasks.start({
prompt: "Draft the Q3 summary.",
tags: ["customer:acme", "team:research"],
});
for await (const task of runner.tasks.list({ tag: "customer:acme" })) {
console.log(task.id, task.tags);
}
await runner.tasks.update(handle.run.task_id, { tags: [] });tags on update replaces the list wholesale — unlike metadata, which
merges. Omit it to leave tags alone; pass [] to clear them, and make sure that
empty array reaches the wire rather than being dropped as falsy.
Files take the same three operations — tags on files.update, tag on
files.list, and tags on the file itself:
await runner.files.update(spec.id, { tags: ["customer:acme"] });
for await (const file of runner.files.list({ tag: "customer:acme" })) {
console.log(file.name, file.tags);
}Tags belong to the file rather than to a version, so they carry forward when a new version is written.
Stamp a cohort onto an end user as they first appear with identity.tags:
const runner = await client.runtimes("support-agent").run({
identity: { user_id: "user_123", tags: ["customer:acme"] },
});Those tags apply only when the assertion mints a new customer member, and are
narrowed server-side to tags the asserting agent member already holds. See
Tags for the access rule and its bounds.
Connectors
client.connectors is a Control Plane namespace, so it hangs off the client
rather than a runner, and acts on the project carried by the authenticated
credential. It exists for the B2B2C flow: define a provider integration once,
then hand each of your own customers an install link so their account connects
to an agent. See Connectors for the model.
const connector = await client.connectors.create({
name: "Slack (support)",
slug: "slack-support",
provider: "slack",
auth_mode: "oauth_stored",
scopes: ["chat:write", "channels:read", "app_mentions:read"],
api_hosts: ["slack.com"],
client_id: process.env.SLACK_CLIENT_ID,
client_secret: process.env.SLACK_CLIENT_SECRET,
});
const install = await client.connectors.authorize(connector.id, {
runtime: "support-agent",
expires_in: 3600,
});
console.log(install.authorize_url, install.expires_at);Create is idempotent on slug, so re-running returns the existing connector.
client_secret and signing_secret are write-only — accepted on create and
update, absent from every read — and omitting one on update leaves the stored
secret unchanged rather than clearing it.
authorize mints the URL you put in front of a customer. It embeds a single-use
state, so never cache the result: two calls give two different URLs. state
itself is never surfaced as a field. Read connector.requires_runtime rather
than hardcoding which providers are chat providers — when it is true, omitting
runtime is a 422. Pass identity to attribute the grant to your own end
user; that mints a customer member, so it can throw ConflictError (409) when
the org has reached its member limit.
connections is nested under connectors and every method takes the connector
id first, mirroring the routes:
for await (const connection of client.connectors.connections.list(connector.id)) {
console.log(connection.id, connection.subject_type, connection.status);
}
await client.connectors.connections.revoke(connector.id, connectionId);connections also has get, create (registered mode, when you already hold a
provider token), and getToken, which resolves a connector credential for the
authenticated subject. For a person_authorized connector getToken can return
authorization_pending with a mission and approval URL instead of a token.
Revoking is revoke, not delete, because it destroys the provider token
behind that connection.
connectors itself has list, create, get, update, delete, and
authorize.
Conversation, event, and metrics reference
Conversations are the durable record of agent work. Lists return summaries with usage, cost, and metrics; item reads return GenAI spans, newest first.
for await (const conversation of runner.conversations.list({
sort: "cost",
direction: "desc",
environment: "production",
})) {
console.log(conversation.id, conversation.cost.usd);
}
for await (const item of runner.conversations.items.list(conversationId)) {
console.log(item.span_id);
}A listed item carries the turn-local delta: its input messages are only the messages new to that turn, so reach for conversations.items.get(conversationId, itemId), which returns the full input history for that span, when you need the complete context behind it.
conversations also has get, retrieve (the latest turn), and the exportJson, exportTrajectory, exportArrow, and exportStream whole-conversation exports.
events.list reads one typed family at a time. event_name is required and narrows the row type: introspection.feedback, introspection.judgement, introspection.observation, introspection.pattern, introspection.pattern.assignment, introspection.observation_clustering.run.
for await (const event of runner.events.list({
event_name: "introspection.feedback",
conversation_id: conversationId,
})) {
console.log(event.timestamp, event.payload.name);
}Use metrics.query for aggregates instead of rows. Every query takes a view, one or more metrics, and a bounded from_timestamp / to_timestamp window — both timestamps are required; metrics does not take the lookback shorthand the list reads accept. The wire contract is those absolute timestamps on every SDK; the Rust SDK’s lookback field is a client-side convenience it lowers to the same window before sending. The query returns data rows of dimensions and metrics, with meta.row_count, meta.row_limit, and meta.truncated telling you whether the result hit the row limit.
const result = await runner.metrics.query({
view: "observations",
metrics: [{ measure: "conversation_id", aggregation: "count_distinct" }],
dimensions: [{ field: "lens" }],
from_timestamp: "2026-07-01T00:00:00Z",
to_timestamp: "2026-07-08T00:00:00Z",
});List methods return a paginator: await it for the first page, or for await to walk every page lazily.
Feedback API reference
track, feedback, and identify connect what your users did to what the agent did. Scope a signal to the conversation and it joins back to the work that produced it.
import { IntrospectionLogs } from "@introspection-sdk/introspection-node/otel";
const analytics = new IntrospectionLogs({ serviceName: "support-app" });
analytics.identify("user_123", { plan: "pro" });
analytics.track("case_closed", { source: "web" });
await analytics.withConversation(conversationId, undefined, async () => {
analytics.feedback("thumbs_up", { comments: "The answer solved it" });
});
await analytics.shutdown();The second argument to withConversation is previousResponseId, which sets the gen_ai.request.previous_response_id baggage entry and anchors the signal to one response instead of the whole conversation; pass undefined when the feedback is about the conversation. IntrospectionLogs also has flush, withUserId, withAnonymousId, withAgent, and withBaggage. The constructor takes token (falling back to INTROSPECTION_TOKEN) alongside serviceName.
You do not need withConversation for a one-off signal: feedback’s options also accept conversationId and previousResponseId inline, alongside comments, and any extra keys are recorded as custom properties of the event. A backend handling a single UI action can pass the join keys directly:
analytics.feedback("thumbs_down", {
conversationId,
previousResponseId: responseId,
comments: "The answer missed the refund policy",
outcome: "case_reopened",
});In a browser, IntrospectionClient from @introspection-sdk/introspection-browser records the same three signals with the same context helpers, plus reset() for sign-out and flush() before a navigation. Give it a browser-safe telemetry token, never a project API key.
import { IntrospectionClient } from "@introspection-sdk/introspection-browser";
const analytics = new IntrospectionClient({ token: browserTelemetryToken });
analytics.identify("user_123", { plan: "pro" });
analytics.track("checkout_completed", { orderId: "ord_123" });Experiments
client.experiments reads /v1/experiments and, called with an id, returns a
handle that opens a runner for the experiment and brackets its lifecycle with
start, end, and cancel. Authoring the experiment itself — arms, goals,
create and delete — is CLI or dashboard work.
const experiment = client.experiments(experimentId);
await experiment.start();
const runner = await experiment.run({ identity: { user_id: "user_123" } });
// ... run tasks through the experiment's sticky arm ...
await experiment.end(); // stops evidence collection; deploys nothingcancel() abandons the experiment instead of concluding it. runner.context
reports the arm the run resolved to via arm_label.
Browser applications
| Entrypoint | Use it for |
|---|---|
@introspection-sdk/introspection-node | Open runners, run tasks, read conversations, events, and metrics from a trusted backend. |
@introspection-sdk/introspection-node/otel | IntrospectionLogs for backend track, feedback, and identify. |
@introspection-sdk/introspection-browser/api | Run and stream tasks from a browser over a short-lived Data Plane session. |
@introspection-sdk/introspection-browser | IntrospectionClient for the same three product signals in a browser. |
The two browser entrypoints are separate clients. Never give either one a project API key or a service-account secret: the /api client takes no token at all, and the telemetry client takes a browser-safe token you mint for it.
Your backend mints a short-lived, identity-bound access token and resolves the runtime id. The browser exchanges that token for an HttpOnly Data Plane session cookie and never talks to the Control Plane.
import { IntrospectionApiClient } from "@introspection-sdk/introspection-browser/api";
const session = () =>
fetch("/api/introspection/session", { method: "POST" }).then((r) => r.json());
const { runtimeId, dpUrl } = await session();
const client = new IntrospectionApiClient({
dpUrl,
getToken: async () => (await session()).token,
});
await client.connect();
const handle = await client.tasks.start({
prompt: "Summarize my open tickets.",
runtime_id: runtimeId,
});getToken must mint a fresh token rather than replay a captured one: it reruns on every exchange, including the 401 recovery that follows an expired session cookie. Reuse one connected client per signed-in user. connect() is optional — tasks, files, conversations, and shares work from construction, so it only front-loads the exchange. See Authentication for the broker flow.
Environment variables
| Variable | Purpose |
|---|---|
INTROSPECTION_TOKEN | API token used when none is passed to a client. |
INTROSPECTION_SERVICE_NAME | Service name attached to product signals. |
INTROSPECTION_BASE_API_URL | Control Plane REST base URL. |
INTROSPECTION_BASE_OTEL_URL | OTLP endpoint for product signals. |
INTROSPECTION_DEV_TARGET | Route SDK traffic to your local development target. |
INTROSPECTION_LOG_LEVEL | SDK log verbosity. |
Errors
Every HTTP failure throws IntrospectionAPIError or one of its subclasses, carrying status, code, requestId, body, and retryAfter. Match with instanceof, and catch the base class to catch everything.
The subclasses are AuthenticationError, InsufficientScopeError, RunnerExpiredError, NotFoundError, ConflictError, ValidationError, RateLimitError, SandboxUnavailableError, and NetworkError — the last one for transport-level failures, with status 0 because no response arrived. InsufficientScopeError adds missingCapability, the capability the token was missing.
import { RunnerExpiredError } from "@introspection-sdk/introspection-node";
try {
await runner.tasks.get(taskId);
} catch (error) {
if (error instanceof RunnerExpiredError) return reopenRunner();
throw error;
}The browser /api entrypoint throws the same classes but re-exports only six of them; import the rest from @introspection-sdk/types.