JavaScript & TypeScript SDK
Install
npm install @introspection-sdk/introspection-nodeNode.js 22 or later. Add @introspection-sdk/introspection-browser for a web application.
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, 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, and identity.
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 });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.
Read what happened
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);
}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, and returns data rows of dimensions and metrics.
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.
Record feedback
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.
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" });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.