Skip to Content
Platform
SDKJavaScript

JS SDK

Run tasks against a deployed runtime from a backend or a browser — TypeScript-first, with typed events and errors — and read the record of what the agent did.

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

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

  1. Run a task and retain its task and run IDs.
  2. Continue, control, and reconnect without duplicating work.
  3. Read the durable conversation after the turn settles.
  4. 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:

  1. 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.
  2. When the user submits, adopt that task, update its placeholder title or metadata, and open the first prompt run.
  3. 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 nothing

cancel() abandons the experiment instead of concluding it. runner.context reports the arm the run resolved to via arm_label.

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