ixi Pixie Run API & SDK
Embed an ixi pixie process in your own product: your server starts runs of a process your team built in ixi, and your frontend hosts the conversation — the end user watches the pixie work, answers its questions, and receives the results.
For AI coding agents: this entire page is mirrored as plain markdown at
https://ixi.so/docs/pixie-agent.txt— fetch that URL for machine reading (https://ixi.so/llms.txtindexes it). Every endpoint, schema, and example here is exact and complete; there are no other endpoints in this API surface. The npm package ispixie-agent.
Concepts
- Pixie — an AI agent that lives on an ixi canvas and works it with tools (running models, editing nodes, asking the user questions).
- Process — a reusable workflow: a canvas template with a resident pixie and a contract of exposed inputs. Built and iterated visually inside ixi.
- Run — one execution of a process: a full clone of the template briefed with concrete inputs. Runs are conversations — the run's pixie works, asks, revises, and submits results.
- This API — lets your server spawn runs and lets your frontend host the run's conversation, so end users never need an ixi account.
Architecture & credentials
| Piece | Runs on | Credential | Scope |
|---|---|---|---|
Run API (/graph/api/*) | your server | org API key ixi_sk_… | spawn runs, mint tickets, read results — org-wide |
SDK (pixie-agent on npm) | your frontend | ticket v1.… | ONE run's conversation, ~15 min per ticket |
| ixi app | your team | ixi login | build the process, watch runs on the canvas |
┌────────────┐ 1. POST /graph/api/runs ┌─────────────┐
│ your server│ ─────(API key)───────────▶ │ api.ixi.so │
│ │ ◀── runId + agent + ticket │ │
└─────┬──────┘ └──────▲──────┘
│ 2. hand ticket to browser │ 3. WebSocket + ticket
┌─────▼──────┐ │ (pixie-agent SDK)
│ your app │ ──────────────────────────────────┘
│ (end user)│ chat · questions · results
└────────────┘
Security rules (non-negotiable):
- The API key must NEVER reach a browser or client bundle. It can spawn runs and read results for the whole org.
- The ticket is the only browser credential. It is bound to one run instance, expires after ~15 minutes, and is refreshed through YOUR backend (which holds the key).
- Ticket refresh endpoints on your backend must authorize against your own user session — only the user who owns a run should get tickets for it.
- Revoking an API key (ixi → org settings → API keys) immediately invalidates the key AND every outstanding ticket it minted.
Setup
- Build the process in ixi: a process node with a resident pixie; define its exposed inputs on the contract; prove it with manual runs.
- Get the process id: right-click the process node on the canvas →
Copy process ID. It is an
ent_…value (the process's template canvas id). The process must belong to your org — personal canvases are rejected (422 process_has_no_org). - Create an API key: ixi → org settings → API keys → Create (org
owner/admin only). The full
ixi_sk_…key is shown exactly once — store it in your server's secret manager.
REST API reference
Base URL: https://api.ixi.so. All three endpoints authenticate with
Authorization: Bearer ixi_sk_… (the org API key). Server-to-server only.
Request and response bodies are JSON.
POST /graph/api/runs — start a run
curl -X POST https://api.ixi.so/graph/api/runs \
-H "Authorization: Bearer $IXI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"process": "ent_1234abcd5678efgh",
"inputs": { "topic": "spring collection", "count": 4 },
"request": "optional free-form instructions for this run",
"name": "optional run name"
}'
Request body:
| field | type | required | meaning |
|---|---|---|---|
process | string | yes | the process id (template canvas id, ent_…) |
inputs | object | no | keys = the process's exposed input ids; values = literal text/urls |
request | string | no | free-form ask that rides the run's briefing |
name | string | no | run display name (auto-derived if omitted) |
Response 200:
{
"runId": "ent_9f8e7d6c5b4a3210",
"name": "spring collection",
"pixie": { "name": "juniper", "epithet": "…", "variant": "…" },
"agent": {
"host": "api.ixi.so",
"agent": "pixie-agent",
"name": "ent_9f8e7d6c5b4a3210:pixie-ab12cd34",
"wsUrl": "wss://api.ixi.so/agents/pixie-agent/ent_9f8e7d6c5b4a3210:pixie-ab12cd34"
},
"ticket": "v1.eyJ….signature",
"expiresAt": 1789000000000
}
Hand agent.host, agent.name, and ticket to your frontend; keep runId mapped
to your own user/session server-side. The run's opening turn is NOT started yet —
the SDK fires it (kickoff) once the user's socket is connected, so the pixie's
first questions reach a live UI.
POST /graph/api/runs/:runId/ticket — re-mint a ticket
curl -X POST https://api.ixi.so/graph/api/runs/ent_9f8e7d6c5b4a3210/ticket \
-H "Authorization: Bearer $IXI_API_KEY"
Response 200: { "runId": "…", "agent": { …same shape as above… }, "ticket": "v1.…", "expiresAt": 1789… }
Call this from your backend when the SDK's getTicket callback fires (reconnects,
expiry). Tickets are cheap and stateless — mint freely.
GET /graph/api/runs/:runId — status + results
curl https://api.ixi.so/graph/api/runs/ent_9f8e7d6c5b4a3210 \
-H "Authorization: Bearer $IXI_API_KEY"
Response 200:
{
"runId": "ent_9f8e7d6c5b4a3210",
"name": "spring collection",
"status": "idle",
"createdAt": 1789000000000,
"inputs": { "topic": "spring collection" },
"request": null,
"results": [
{ "id": "gen_…", "url": "https://s.ixi.so/…", "thumb": "https://s.ixi.so/…", "text": null, "group": "posters", "createdAt": 1789000123456 }
]
}
status:"working"(a turn is in flight) or"idle"(waiting).results: everything the pixie has submitted as deliverables (submit_result), in order. Images/videos carryurl/thumb; text deliverables carrytext.- Completion is a convention, not a state: treat
status: "idle"with non-emptyresultsas done, or simply end the session when your user is satisfied. Poll politely (≥ a few seconds apart, only while a run is open).
Errors (all endpoints)
| status | body error | meaning |
|---|---|---|
| 401 | unauthorized | missing/invalid/revoked API key |
| 404 | process not found / run not found | id doesn't exist or belongs to another org (indistinguishable by design) |
| 422 | process_has_no_org and others | spawn refused — the body's error says why |
| 429 | daily run limit reached | per-key spawn cap (default 200/day) |
| 503 | external run API not configured | server-side ticket secret not provisioned |
SDK — pixie-agent
npm install pixie-agent
# React entry only — peer deps:
npm install react agents
Two entries:
pixie-agent— framework-freePixieSessionclass. Zero runtime dependencies; needs nativeWebSocket+fetch(all browsers, Node ≥ 22; passwebSocket:to polyfill older Node).pixie-agent/react—usePixieRunhook built on the first-party Cloudflare Agents hooks (peer deps:react,agents@^0.17).
React: usePixieRun
import { usePixieRun } from 'pixie-agent/react';
function PixieRunView({ host, agentName, ticket }: { host: string; agentName: string; ticket: string }) {
const run = usePixieRun({
host, // "api.ixi.so" (from agent.host)
agentName, // "ent_…:pixie-…" (from agent.name — keep the raw colon)
ticket, // from your server
getTicket: () => // ticket refresh via YOUR backend
fetch('/api/pixie-ticket', { method: 'POST' }).then((r) => r.json()).then((r) => r.ticket),
autoKickoff: true, // fire the run's opening turn once connected (default true)
});
return (
<div>
{run.messages.map((m) => <MessageBubble key={m.id} message={m} />)}
{run.pendingInteractions.map((p) => (
<InteractionCard key={p.toolCallId} interaction={p}
onAnswer={(output) => run.submitToolResult(p.toolCallId, output)} />
))}
<Composer disabled={run.isStreaming} onSend={(text) => run.sendMessage(text)} />
{run.error && <ErrorBanner error={run.error} />}
</div>
);
}
Hook return value:
{
messages: UIMessage[]; // full transcript, streams live
sendMessage(text: string, metadata?: object): Promise<void>;
pendingInteractions: PendingInteraction[]; // unanswered questions (see below)
submitToolResult(toolCallId: string, output: unknown): void;
isStreaming: boolean;
error: Error | null;
abort(): void; // cancel the in-flight turn
}
Vanilla: PixieSession
import { PixieSession } from 'pixie-agent';
const session = new PixieSession({
host: 'api.ixi.so',
agentName: 'ent_…:pixie-…', // raw colon — never URL-encode it
ticket,
getTicket: () => fetch('/api/pixie-ticket', { method: 'POST' }).then((r) => r.json()).then((r) => r.ticket),
});
session.on('messages-changed', (messages) => render(messages));
session.on('interaction', (p) => showQuestionCard(p)); // pixie asked something
session.on('status', (s) => setSpinner(s === 'streaming'));
session.on('error', (e) => showError(e));
await session.connect(); // opens the socket + loads history
await session.kickoff(); // fires the opening turn (idempotent server-side)
await session.send('make the second one warmer');
session.submitToolResult(toolCallId, output);
session.abort(); // cancel the in-flight turn
session.disconnect(); // leaving the page
Full class surface:
class PixieSession {
constructor(opts: {
host: string; // "api.ixi.so" (https:// origin also accepted)
agentName: string; // "<runId>:<pixieNodeId>"
ticket: string;
getTicket?: () => Promise<string>; // refresh via your backend (recommended)
webSocket?: typeof WebSocket; // Node < 22 polyfill
});
connect(): Promise<void>;
kickoff(): Promise<void>;
send(text: string, extra?: { metadata?: Record<string, unknown> }): Promise<void>;
submitToolResult(toolCallId: string, output: unknown): void;
abort(): void;
disconnect(): void;
readonly messages: UIMessage[];
readonly pendingInteractions: PendingInteraction[];
readonly status: 'idle' | 'connecting' | 'connected' | 'streaming' | 'reconnecting' | 'closed' | 'error';
on(ev: 'messages-changed', cb: (m: UIMessage[]) => void): () => void; // returns unsubscribe
on(ev: 'interaction', cb: (p: PendingInteraction) => void): () => void;
on(ev: 'status', cb: (s: SessionStatus) => void): () => void;
on(ev: 'error', cb: (e: Error) => void): () => void;
}
Reconnects are automatic (exponential backoff); the session re-mints its ticket via
getTicket when the old one nears expiry and resyncs the transcript before any
send. Without getTicket, a reconnect after ~15 minutes fails with a 401.
Messages
messages are AI-SDK–style UI messages:
interface UIMessage {
id: string;
role: 'user' | 'assistant' | 'system';
parts: UIMessagePart[];
metadata?: Record<string, unknown>;
}
Render by part type:
| part shape | render as |
|---|---|
{ type: 'text', text } | the message body (markdown-friendly) |
{ type: 'reasoning', text } | optional collapsible "thinking" section |
{ type: 'file', url, mediaType, filename } | attachment preview |
{ type: 'tool-<name>' | 'dynamic-tool', toolCallId, state, input, output } | activity chip ("running run_model…") — or hide |
Tool part state progresses input-streaming → input-available →
output-available | output-error. Most tool parts are the pixie's internal work;
the only ones needing real UI are the three interaction tools below, and the SDK
surfaces those via pendingInteractions so you never scan parts yourself.
Exported helpers for custom rendering: isToolPart, toolPartName,
INTERACTION_TOOLS, computePendingInteractions.
Interaction tools (the pixie's questions)
When the pixie needs the end user, the turn PAUSES until every pending interaction
is answered via submitToolResult(toolCallId, output). An unanswered interaction
stalls the run forever — every UI path through a rendered card must end in a
submitToolResult call.
interface PendingInteraction {
toolCallId: string;
toolName: 'clarify_from_user' | 'request_approval' | 'give_user_options';
input: unknown; // the tool-specific input below
messageId: string;
}
clarify_from_user — question cards
Input (what you render):
{
message?: string; // one line of context above the cards
questions: Array<{ // 1–6 cards, all answered in one submit
question: string; // markdown ok
multiSelect?: boolean; // default single-choice
options: Array<{
label: string;
description?: string;
recommended?: boolean; // pre-select these (the pixie's suggestion)
nodeId?: string; // option IS a canvas node's media — render it
ref?: string; // "nodeId#genId" — one specific generation
url?: string; // direct media url
mediaType?: 'image' | 'video' | 'audio';
}>;
}>;
}
Output (what you submit) — one answer per question, in order:
{
"answers": [
{
"question": "…exact question text…",
"selected": ["chosen label", "another label"],
"other": "free text the user typed (optional)",
"nodeIds": ["…"], "refs": ["…"], "urls": ["…"]
}
]
}
Rules: always render an "Other" free-text box per card; selected may be empty if
the user only typed an Other answer; echo nodeIds/refs/urls for chosen media
options so the pixie acts on the exact items.
request_approval — one-click go/no-go
Input: { title: string, details?: string, approveLabel?: string, declineLabel?: string }
Output: { "approved": true } — or, when declining with feedback:
{ "approved": false, "requestedChanges": [{ "step": "…plan step text…", "comment": "…user comment…" }] }
give_user_options — legacy single select
Input { message: string, options: string[] } → output { "selectedOption": "…" }.
Rare (old processes only), but handle it or the turn hangs.
Limits, billing, lifecycle
- Tickets: ~15-minute TTL, bound to one run instance, ride as a
?ticket=query param. Stateless HMAC — revoking the issuing API key invalidates them instantly. - Spawn cap: 200 runs per API key per 24 h (429 beyond it).
- Billing: runs bill the org that owns the process — pixie turns (token cost +15%) and any model executions, identical to runs started inside ixi. An org out of credits gets a polite "out of credits" assistant message instead of a turn.
- Transcripts persist: one run = one pixie = one durable conversation. Reconnecting any time later resumes exactly where it left off.
- Observability: runs appear in the process node's run table inside ixi — your team can watch, QA, or take over any run from the canvas.
Troubleshooting
| symptom | likely cause |
|---|---|
POST /graph/api/runs → 401 | wrong/revoked key, or header isn't Authorization: Bearer ixi_sk_… |
| → 404 | process isn't the template canvas id (right-click the node → Copy process ID), or another org's |
→ 422 process_has_no_org | the process lives on a personal canvas — recreate it under the org |
| → 503 | the run API isn't provisioned server-side |
| WS connects then closes / 401 | ticket expired or for a different run — check getTicket wiring |
| pixie never says anything | kickoff never fired — keep autoKickoff: true or call session.kickoff() after connect() |
| turn stuck forever | a rendered interaction card was never answered — every pendingInteractions entry needs submitToolResult |
| 404s when building URLs by hand | the agent name's : was URL-encoded — always use the raw colon |
Machine-readable summary
api:
base: https://api.ixi.so
auth: "Authorization: Bearer ixi_sk_<key>" # server-side only
endpoints:
- { method: POST, path: /graph/api/runs, body: { process: string, inputs?: object, request?: string, name?: string }, returns: { runId, name, pixie, agent: { host, agent, name, wsUrl }, ticket, expiresAt } }
- { method: POST, path: "/graph/api/runs/{runId}/ticket", returns: { runId, agent, ticket, expiresAt } }
- { method: GET, path: "/graph/api/runs/{runId}", returns: { runId, name, status: "working|idle", createdAt, inputs, request, results: [{ id, url, thumb, text, group, createdAt }] } }
sdk:
npm: pixie-agent
entries:
- { import: "pixie-agent", exports: [PixieSession, computePendingInteractions, isToolPart, toolPartName, INTERACTION_TOOLS] }
- { import: "pixie-agent/react", exports: [usePixieRun], peerDeps: [react, "agents@^0.17"] }
browser_credential: ticket (query param, ~15 min TTL, refresh via getTicket -> your backend -> POST /ticket)
interaction_tools:
clarify_from_user: { output: { answers: [{ question, selected: [string], other?, nodeIds?, refs?, urls? }] } }
request_approval: { output: { approved: boolean, requestedChanges?: [{ step, comment }] } }
give_user_options: { output: { selectedOption: string } }
invariants:
- api key never in a browser
- agent name keeps its raw colon (never URL-encode)
- every pending interaction must be answered or the turn stalls
- completion convention: status idle + results non-empty