# 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 ```