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-nodeInitialize
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
| Accessor | What it manages |
|---|---|
client.runtimes | Runtime list/get, name or group resolution, and (idOrName) => handle. |
client.experiments | Experiment list/get, lifecycle, and (id) => handle; use the CLI for the current create document. |
client.recipes | Recipe 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:
| Field | Use for |
|---|---|
user_id | A known, authenticated user. |
anonymous_id | An unidentified visitor. |
conversation_id | Continuity 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:
| Method | What 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:
| Kind | What it does |
|---|---|
prompt | Opens a fresh turn from a prompt. |
steer | Injects guidance into the in-progress turn (falls back to prompt if none is active). |
clear | Resets 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.
| Family | Kind | A row is… |
|---|---|---|
introspection.observation | state | an observation, as it currently is — supersession applied, current pattern assignment resolved |
introspection.pattern | state | a pattern catalog row, as it currently is — current name/status plus lifecycle timestamps |
introspection.feedback | stream | one user feedback signal |
introspection.judgement | stream | one judge verdict |
introspection.pattern.assignment | stream | one observation-to-pattern assignment |
introspection.observation_clustering.run | stream | one 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-arrowWith 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); // revokeExperiments
Create an experiment with the CLI’s current ExperimentCreate document:
introspection experiments create --file experiment.yamlOnce it exists, use either the SDK or CLI for its lifecycle and traffic. SDK comes first:
JavaScript SDK
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.
Related
- Runtimes, Tasks, Experiments: the underlying model.
- Authentication: tokens, scopes, and environments.
- API Reference: the full REST surface.