Skip to Content
Platform
SDKs & CLIJavaScript & TypeScript

JavaScript & TypeScript SDK

Run tasks against a deployed runtime from a backend or a browser, give the agent durable inputs, and read the record of what it did.

Install

npm install @introspection-sdk/introspection-node

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

EntrypointUse it for
@introspection-sdk/introspection-nodeOpen runners, run tasks, read conversations, events, and metrics from a trusted backend.
@introspection-sdk/introspection-node/otelIntrospectionLogs for backend track, feedback, and identify.
@introspection-sdk/introspection-browser/apiRun and stream tasks from a browser over a short-lived Data Plane session.
@introspection-sdk/introspection-browserIntrospectionClient 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

VariablePurpose
INTROSPECTION_TOKENAPI token used when none is passed to a client.
INTROSPECTION_SERVICE_NAMEService name attached to product signals.
INTROSPECTION_BASE_API_URLControl Plane REST base URL.
INTROSPECTION_BASE_OTEL_URLOTLP endpoint for product signals.
INTROSPECTION_DEV_TARGETRoute SDK traffic to your local development target.
INTROSPECTION_LOG_LEVELSDK 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.

Last updated on