ixi

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

PieceRuns onCredentialScope
Run API (/graph/api/*)your serverorg API key ixi_sk_…spawn runs, mint tickets, read results — org-wide
SDK (pixie-agent on npm)your frontendticket v1.…ONE run's conversation, ~15 min per ticket
ixi appyour teamixi loginbuild 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

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:

fieldtyperequiredmeaning
processstringyesthe process id (template canvas id, ent_…)
inputsobjectnokeys = the process's exposed input ids; values = literal text/urls
requeststringnofree-form ask that rides the run's briefing
namestringnorun 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 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)

statusbody errormeaning
401unauthorizedmissing/invalid/revoked API key
404process not found / run not foundid doesn't exist or belongs to another org (indistinguishable by design)
422process_has_no_org and othersspawn refused — the body's error says why
429daily run limit reachedper-key spawn cap (default 200/day)
503external run API not configuredserver-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-free PixieSession class. Zero runtime dependencies; needs native WebSocket + fetch (all browsers, Node ≥ 22; pass webSocket: to polyfill older Node).
  • pixie-agent/reactusePixieRun hook 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 shaperender 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-streaminginput-availableoutput-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

symptomlikely cause
POST /graph/api/runs → 401wrong/revoked key, or header isn't Authorization: Bearer ixi_sk_…
→ 404process isn't the template canvas id (right-click the node → Copy process ID), or another org's
→ 422 process_has_no_orgthe process lives on a personal canvas — recreate it under the org
→ 503the run API isn't provisioned server-side
WS connects then closes / 401ticket expired or for a different run — check getTicket wiring
pixie never says anythingkickoff never fired — keep autoKickoff: true or call session.kickoff() after connect()
turn stuck forevera rendered interaction card was never answered — every pendingInteractions entry needs submitToolResult
404s when building URLs by handthe 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