# 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.txt` indexes it). Every endpoint,
> schema, and example here is exact and complete; there are no other endpoints in
> this API surface. The npm package is `pixie-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):**
1. The API key must NEVER reach a browser or client bundle. It can spawn runs and
read results for the whole org.
2. 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).
3. 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.
4. Revoking an API key (ixi → org settings → API keys) immediately invalidates
the key AND every outstanding ticket it minted.
## Setup
1. **Build the process** in ixi: a process node with a resident pixie; define its
exposed inputs on the contract; prove it with manual runs.
2. **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`).
3. **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
```bash
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`:
```json
{
"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
```bash
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
```bash
curl https://api.ixi.so/graph/api/runs/ent_9f8e7d6c5b4a3210 \
-H "Authorization: Bearer $IXI_API_KEY"
```
Response `200`:
```json
{
"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 carry `url`/`thumb`; text deliverables carry `text`.
- **Completion is a convention, not a state**: treat `status: "idle"` with
non-empty `results` as 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`
```sh
npm install pixie-agent
# React entry only — peer deps:
npm install react agents
```
Two entries:
- `pixie-agent` — framework-free `PixieSession` class. **Zero runtime
dependencies**; needs native `WebSocket` + `fetch` (all browsers, Node ≥ 22;
pass `webSocket:` to polyfill older Node).
- `pixie-agent/react` — `usePixieRun` hook built on the first-party Cloudflare
Agents hooks (peer deps: `react`, `agents@^0.17`).
### React: `usePixieRun`
```tsx
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 (
{run.messages.map((m) => )}
{run.pendingInteractions.map((p) => (
run.submitToolResult(p.toolCallId, output)} />
))}
run.sendMessage(text)} />
{run.error && }
);
}
```
Hook return value:
```ts
{
messages: UIMessage[]; // full transcript, streams live
sendMessage(text: string, metadata?: object): Promise;
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`
```ts
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:
```ts
class PixieSession {
constructor(opts: {
host: string; // "api.ixi.so" (https:// origin also accepted)
agentName: string; // ":"
ticket: string;
getTicket?: () => Promise; // refresh via your backend (recommended)
webSocket?: typeof WebSocket; // Node < 22 polyfill
});
connect(): Promise;
kickoff(): Promise;
send(text: string, extra?: { metadata?: Record }): Promise;
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:
```ts
interface UIMessage {
id: string;
role: 'user' | 'assistant' | 'system';
parts: UIMessagePart[];
metadata?: Record;
}
```
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-' \| '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.
```ts
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):
```ts
{
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:
```json
{
"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:
```json
{ "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
```yaml
api:
base: https://api.ixi.so
auth: "Authorization: Bearer ixi_sk_" # 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
```