Skip to Content
Platform
SDK & CLIJavaScript SDK

JavaScript SDK

@introspection-sdk/introspection-node is the platform client: read and run runtimes, drive tasks, and work with experiments, recipes, files, conversations, and shares from Node.js.

The package exposes Introspection’s REST API through IntrospectionClient. Two layers sit under it: top-level resources for reading and running runtimes and managing experiments and recipes, and a runner for working inside one execution. The runner is a live handle to a sandbox returned by client.runtimes(name).run().

Install

npm install @introspection-sdk/introspection-node

Initialize

The client reads its token from INTROSPECTION_TOKEN if you don’t pass one. The base API URL defaults to https://api.introspection.dev and can be overridden through advanced.baseApiUrl.

import { IntrospectionClient } from "@introspection-sdk/introspection-node"; const client = new IntrospectionClient({ token: process.env.INTROSPECTION_TOKEN, advanced: { // Point at a non-default API (e.g. a local environment). baseApiUrl: "https://api.introspection.dev", }, }); // ... use the client ... await client.shutdown();

The project is taken from the API key server-side, so most calls need no project_id. See Authentication for how keys are scoped.

Resources at a glance

AccessorWhat it manages
client.runtimesRuntime list/get, name or group resolution, and (idOrName) => handle.
client.experimentsExperiment list/get, lifecycle, and (id) => handle; use the CLI for the current create document.
client.recipesRecipe CRUD (immutable build artefacts).
client.shutdown()Releases the client. Call it when you’re done.

Runtimes and the runner

client.runtimes("name") returns a handle that resolves the name (or UUID) lazily. Calling .run({ identity }) on it opens a Runner, a live handle to an execution sandbox.

const runner = await client.runtimes("support-agent").run({ identity: { user_id: "user_123" }, agent_name: "agent", scope: "tasks:read tasks:write", }); // runner.context, runner.session_id, runner.expires_at are available here. await runner.close();

The identity binds the session to an end user. Supply exactly one of:

FieldUse for
user_idA known, authenticated user.
anonymous_idAn unidentified visitor.
conversation_idContinuity keyed to a conversation.

A runner exposes four namespaces (runner.tasks, runner.files, runner.conversations, runner.shares) and runner.close(), which flips a local flag so later calls fail fast. client.runtimes intentionally exposes only list, get, resolve, runById, and the callable runtime handle. Create, route, deactivate, or reactivate runtime versions through the CLI or dashboard so application code cannot mutate deployment state by accident.

Runner creation also accepts caller and ttl_seconds. The resolved runner.context records the runtime and runtime group, experiment and arm when applicable, flat recipe revision fields, selected agent_name, end-user identity, and caller metadata for attribution.

A runner does not auto-refresh its session token; the platform refreshes it server-side for the session’s lifetime. runner.refresh() is a manual escape hatch that mints a brand-new spec. You rarely need it.

See Runtimes for the underlying model.

Tasks and runs

A task is one execution; a run is a segment of work within it. runner.tasks.create({ prompt }) creates a task and returns { task, run }. The sugar runner.tasks.start({ prompt }) does the same but returns a RunHandle directly, the handle you stream.

import { EventType } from "@introspection-sdk/introspection-node"; // Create + get a RunHandle in one call. const handle = await runner.tasks.start({ prompt: "Summarize my open tickets" }); // Stream the run as typed AG-UI events. for await (const ev of handle.stream()) { if (ev.type === EventType.TEXT_MESSAGE_CONTENT) process.stdout.write(ev.delta ?? ""); }

AG-UI events. The stream emits AG-UI  events, the open Agent User Interaction Protocol for streaming agent output. Each event is a typed AGUIEvent from @ag-ui/core, and the EventType enum (TEXT_MESSAGE_CONTENT, TOOL_CALL_START, RUN_FINISHED, and so on) is re-exported from the SDK, so you switch on ev.type rather than parsing strings. See the AG-UI event reference  for the full schema.

A RunHandle exposes three methods:

MethodWhat it does
.stream()Async-iterable of typed AG-UI events (AGUIEvent) for the run.
.text()Concatenates the assistant text deltas (TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_CHUNK) into one string.
.cancel(options?)Aborts immediately by default, or drains before teardown.
const active = await runner.tasks.start({ prompt: "Draft a reply" }); // Bodyless cancel remains an immediate abort and keeps the sandbox warm. await active.cancel(); // Or, for release/teardown, drain instead of aborting: // await active.cancel({ mode: "drain", drain_within_seconds: 60 });

Follow-up turns open a new run on the same task via runner.tasks.runs.create(taskId, { kind, ... }). The kind expresses intent:

KindWhat it does
promptOpens a fresh turn from a prompt.
steerInjects guidance into the in-progress turn (falls back to prompt if none is active).
clearResets context to a new conversation in the same sandbox; takes no prompt text.
// A follow-up "prompt" turn. The task id comes from the handle's `.run` — // a RunHandle exposes `.task` / `.run` objects, not a flat `task_id`. const followUp = await runner.tasks.runs.create(handle.run.task_id, { kind: "prompt", prompt: { text: "Now draft the reply" }, }); for await (const ev of followUp.stream()) { if (ev.type === EventType.TEXT_MESSAGE_CONTENT) process.stdout.write(ev.delta ?? ""); } // A "steer" turn injects guidance into the in-progress run via `message`. const steer = await runner.tasks.runs.create(handle.run.task_id, { kind: "steer", message: "Keep it under three sentences", }); await steer.cancel();

On tasks.runs.create, prompt is a TaskPrompt object ({ text, images? }), not the bare string that tasks.start / tasks.create accept. Passing a string — or reaching for a flat handle.task_id that doesn’t exist on the handle — fails the request with model_attributes_type / uuid_parsing 422s. Pass prompt: { text: "..." } and take the id from handle.run.task_id.

runner.tasks also supports list, get, update, delete, archive, and unarchive. To fork from a shared conversation, pass fork_share_id to tasks.create. See Tasks for the lifecycle.

Interrupts and resume

A run can pause to ask the caller for input, for example a tool permission or a confirmation. When it does, the task moves to awaiting_user and the stream ends with a RUN_FINISHED event whose outcome is an interrupt carrying one or more Interrupt objects.

import { EventType } from "@introspection-sdk/introspection-node"; const handle = await runner.tasks.start({ prompt: "Refund order #123" }); let interrupts; for await (const ev of handle.stream()) { if (ev.type === EventType.RUN_FINISHED && ev.outcome?.type === "interrupt") { interrupts = ev.outcome.interrupts; // Interrupt[]: { id, reason, message?, responseSchema?, ... } } }

Answer the interrupts by opening a resume run on the same task. Each entry references an interruptId and a status of "resolved" (optionally with a payload) or "cancelled":

const resumed = await runner.tasks.runs.resume(handle.run.task_id, { resume: [{ interruptId: interrupts[0].id, status: "resolved", payload: { approved: true } }], }); for await (const ev of resumed.stream()) { // the run continues from where it paused }

Resume lives on runner.tasks.runs.resume(taskId, { resume }), not on tasks. You can answer several interrupts in one call; resolving all of them flips the task back to running, while answering only some keeps it awaiting_user.

Files

runner.files reads and writes durable files and their versions.

// Upload bytes (or a Blob). const file = await runner.files.upload({ file: new Uint8Array(buf), name: "report.pdf", contentType: "application/pdf", }); // Create a text file inline. const note = await runner.files.createText({ name: "summary.md", content: "# Summary\n...", mime_type: "text/markdown", }); // Download raw bytes. const bytes = await runner.files.download(file.id); // Walk version history. for await (const v of runner.files.versions.list(file.id)) { console.log(v.version, v.size_bytes); }

Other methods: files.list, files.get, files.update, files.delete, files.downloadStream, and files.versions.get / files.versions.create.

Conversations

runner.conversations is read-only: the replayable record a task leaves behind.

// List conversation summaries (auto-pages on `for await`). for await (const c of runner.conversations.list()) { console.log(c.conversation_id, c.total_tokens, c.total_cost_usd); } // Sort and filter the list: most expensive production conversations first. for await (const c of runner.conversations.list({ sort: "cost", direction: "desc", environment: "production", request_model: "claude-sonnet-5", })) { console.log(c); } // Responses-API-style snapshot of the latest LLM turn (or pass an item id). const turn = await runner.conversations.retrieve(conversationId); // Walk the transcript item by item. for await (const item of runner.conversations.items.list(conversationId, { order: "asc", })) { console.log(item.id); }

list() sorts by created (default), duration, turns, tokens, or cost (ConversationSortField), and filters on lineage (environment, runtime_id, runtime_group_id, experiment_id, recipe_git_commit_sha), request_model, agent_name, status, service, and a start_date/end_date window. Each ConversationSummary carries triage aggregates — token totals (total_tokens), total_cost_usd, tool_use_count / failed_tool_use_count — plus first_request_model and first_agent_name for a stable preview identity.

retrieve() returns null when the conversation has no items. items.get(conversationId, itemId) fetches a single item with the full input history for that span.

Every list is a Paginator: await it for the first page and its envelope metadata (records, count, total_count, next), or for await it to stream every row across pages, fetching each page only as the iterator reaches it — stop early to stop fetching. A page returns up to 1000 rows (limit, default 100); the SDK follows the opaque next cursor for you.

Events

runner.events is a read-only cursor list over the platform event stream (GET /v1/events), mirroring runner.conversations. Every read names its family: event_name is required, exactly one of the six canonical platform families.

FamilyKindA row is…
introspection.observationstatean observation, as it currently is — supersession applied, current pattern assignment resolved
introspection.patternstatea pattern catalog row, as it currently is — current name/status plus lifecycle timestamps
introspection.feedbackstreamone user feedback signal
introspection.judgementstreamone judge verdict
introspection.pattern.assignmentstreamone observation-to-pattern assignment
introspection.observation_clustering.runstreamone clustering run

Because a request names one family, every page is homogeneous: each row shares a common envelope (id, timestamp, event_name, trace_id/span_id, conversation_id, service_name, environment, and lineage — runtime_group_id, runtime_id, experiment_id, recipe_git_commit_sha) plus a nested, family-typed payload. For the two state families the server folds the event history and answers with resolved current state; the four stream families return plain event rows. “A pattern’s observations” is the observation family filtered by pattern.

// Feedback for a conversation, newest first — rows are typed to the // requested family, so `ev` narrows to FeedbackEvent here. for await (const ev of runner.events.list({ event_name: "introspection.feedback", conversation_id: conversationId, order: "desc", })) { console.log(ev.timestamp, ev.payload.name, ev.payload.comments); } // A pattern's observations: resolved current state. for await (const obs of runner.events.list({ event_name: "introspection.observation", pattern_id: patternId, })) { console.log(obs.payload.lens, obs.payload.summary); }

TypeScript narrows the row type from the event_name literal you pass ("introspection.feedback"FeedbackEvent, and so on); a family this SDK version doesn’t know falls back to UnknownEvent rather than failing. Envelope filters (time window, conversation_id, service_name, environment, lineage, trace_id/span_id) apply to every family; family-scoped filters — observation: lens, pattern_id, include_superseded, severities, conversation_ids; pattern: lens, status — are validated server-side against the requested family. Omitting event_name is an error. Rows outside the six families (custom track() events, gen_ai.* records) are not enumerable here; they remain measurable through the metrics API.

runner.metrics runs the bounded aggregate query (POST /v1/metrics) — see Conversations → Querying and measuring.

Efficient bulk downloads (Arrow)

Both runner.conversations.list and runner.events.list can return each page as an Apache Arrow  IPC stream instead of JSON — pass format: "arrow" to move the column data over the wire in Arrow’s compact binary layout and skip per-row JSON parsing when pulling large histories:

for await (const ev of runner.events.list({ event_name: "introspection.judgement", lookback: "7d", format: "arrow", })) { console.log(ev.id, ev.payload.result); }

For columnar consumption, skip the row objects entirely with the .arrow() accessor: it async-iterates one apache-arrow Table per server page — constant envelope columns plus a typed payload struct column fixed by the requested event_name — and .readAll() fetches and concatenates every page into a single Table. It takes the same params as list() (minus format), and runner.conversations.arrow(...) works the same way.

const table = await runner.events .arrow({ event_name: "introspection.observation", lookback: "30d" }) .readAll();

Arrow is an optional dependency: the default JSON path never loads it, so install apache-arrow only when you opt in.

npm install apache-arrow

With format: "arrow" on list(), pagination, filtering, and the returned row shapes are identical to the JSON path — only the wire encoding changes. Without the peer installed, the Arrow paths throw a clear error pointing at the missing dependency.

Shares

runner.shares creates read-only grants for a file or conversation. A grant carries a url with the ?share_id capability; pass that grant’s id as fork_share_id to fork a task.

const share = await runner.shares.create({ resource_type: "conversation", resource_id: conversationId, // omit `granted_member_id` for a project-wide grant }); await runner.shares.list({ created_by_me: true }); await runner.shares.get(share.id); await runner.shares.delete(share.id); // revoke

Experiments

Create an experiment with the CLI’s current ExperimentCreate document:

introspection experiments create --file experiment.yaml

Once it exists, use either the SDK or CLI for its lifecycle and traffic. SDK comes first:

await client.experiments(EXPERIMENT_ID).start(); const runner = await client.experiments(EXPERIMENT_ID).run({ identity: { user_id: "user_123" }, }); const task = await runner.tasks.start({ prompt: "Help me with my order." }); await task.text(); await runner.close(); const evidence = await client.experiments.get(EXPERIMENT_ID); console.log(evidence.status, evidence.arms); await client.experiments(EXPERIMENT_ID).end();

.run() returns the same Runner shape as a runtime run. Current experiment arms are runtime IDs with arm_label; they have no authored control arm or per-arm weight. Ending an experiment records that evidence collection stopped and accepts no winner. See Experimenting & shipping for the complete supported loop.

Recipes

client.recipes is CRUD over immutable build artefacts (repository + git ref + commit SHA + optional sub-path), referenced by runtimes and experiment arms.

const recipe = await client.recipes.create({ project: PROJECT_ID, repository_id: REPO_ID, name: "support-agent", git_ref: "main", git_commit_sha: "a1b2c3d4...", sub_path: "agents/support", }); for await (const r of client.recipes.list({ project: PROJECT_ID })) { console.log(r.slug, r.git_commit_sha); }

Also: recipes.get, recipes.update, recipes.delete.

Other clients. The same platform API is available in the Python SDK. For client-side apps, the browser client is @introspection-sdk/introspection-browser/api (IntrospectionApiClient), which authenticates with a cookie session minted by your backend token broker rather than a raw API key.

Last updated on